Compare commits
44 Commits
1c13ebd76f
...
da8b2ed07b
| Author | SHA1 | Date | |
|---|---|---|---|
| da8b2ed07b | |||
| e0f334b704 | |||
| 6a69adbc28 | |||
| 37b3ae7ae8 | |||
| 5178776005 | |||
| d9c50de196 | |||
| 4977f4ac0a | |||
| dba47aa656 | |||
| 11f0634721 | |||
| 0cff09ccd0 | |||
| 1fca9b5ff7 | |||
| 7c493c656a | |||
| 7bd03864b5 | |||
| 82948ada92 | |||
| 36e721e742 | |||
| c46c8414b8 | |||
| 9f65d427a0 | |||
| f708fb7768 | |||
| 1a774088a1 | |||
| f6d91c92b6 | |||
| e86f3fa299 | |||
| 8091cf8802 | |||
| 500d090dfc | |||
| bc2a6219fe | |||
| 1425856af5 | |||
| ee1fcfbee6 | |||
| 79d8a71c97 | |||
| 9586aecfdc | |||
| 4e23899f87 | |||
| 2546f8abbe | |||
| 92c8517663 | |||
| 197dcadfee | |||
| 59565c9b8a | |||
| 70a08ae1e9 | |||
| 1e7dd064f9 | |||
| 8d9e8998f8 | |||
| a2847092ac | |||
| ba581744ec | |||
| 99a4cae572 | |||
| e5615826d6 | |||
| f8e54d0f8b | |||
| 09a66bd3d5 | |||
| 092271790c | |||
| 64d177e791 |
@@ -95,7 +95,10 @@
|
||||
"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)",
|
||||
"Bash(git revert *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -148,6 +148,7 @@ backend/.env.test
|
||||
# Test images uploaded during development
|
||||
_images.tests/
|
||||
_images/
|
||||
images/
|
||||
|
||||
# ── Local AI Tooling & Persistent Paths ──────────────────────
|
||||
.tool_paths
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.14.3",
|
||||
"lastUpdated": "2026-04-21",
|
||||
"phase": "Phase 3 Complete - Image Confirmation UX"
|
||||
"version": "1.14.5",
|
||||
"lastUpdated": "2026-04-22",
|
||||
"phase": "Phase 3 Complete - Original Image Storage for Debug"
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ from slowapi.util import get_remote_address
|
||||
from pathlib import Path
|
||||
from .. import models, schemas, auth
|
||||
from ..database import get_db
|
||||
from ..services.image_processing import ImageProcessor
|
||||
from ..services.image_processing import ImageProcessor, strip_exif_orientation
|
||||
from ..services.image_storage import save_image, get_unique_filename
|
||||
from ..logger import log
|
||||
|
||||
# [H-02] Rate limiter for extract-label endpoint
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
@@ -103,7 +104,13 @@ async def extract_label(
|
||||
detail="File exceeds 10MB limit."
|
||||
)
|
||||
|
||||
result = extract_label_info(contents, mode=mode)
|
||||
# Strip EXIF orientation so Gemini analyzes raw image (not rotated)
|
||||
# Backend will process the same raw image
|
||||
contents_no_exif = strip_exif_orientation(contents)
|
||||
log.info(f"[EXTRACT] Sending {len(contents_no_exif)} bytes to Gemini (EXIF orientation stripped)")
|
||||
|
||||
result = extract_label_info(contents_no_exif, mode=mode)
|
||||
log.info(f"[EXTRACT] Gemini returned: {type(result).__name__}")
|
||||
return result
|
||||
|
||||
@router.post("/", response_model=schemas.Item, status_code=status.HTTP_201_CREATED)
|
||||
@@ -152,6 +159,11 @@ def create_item(
|
||||
try:
|
||||
import base64
|
||||
image_bytes = base64.b64decode(item.extracted_image_bytes)
|
||||
log.info(f"[CREATE_ITEM] Received {len(image_bytes)} bytes for photo processing (base64 decoded)")
|
||||
|
||||
# Strip EXIF orientation to match what Gemini analyzed
|
||||
image_bytes = strip_exif_orientation(image_bytes)
|
||||
log.info(f"[CREATE_ITEM] After EXIF strip: {len(image_bytes)} bytes")
|
||||
|
||||
photo_result = _auto_save_photo_from_extraction(
|
||||
item_id=db_item.id,
|
||||
@@ -164,10 +176,8 @@ def create_item(
|
||||
if photo_result["status"] == "ok":
|
||||
db.refresh(db_item) # Reload to get updated photo fields
|
||||
else:
|
||||
from ..logger import log
|
||||
log.warning(f"Photo auto-save skipped for item {db_item.id}: {photo_result.get('reason')}")
|
||||
except Exception as e:
|
||||
from ..logger import log
|
||||
log.error(f"Exception during auto-save for item {db_item.id}: {e}")
|
||||
# Don't fail item creation
|
||||
|
||||
@@ -276,6 +286,25 @@ def delete_item(
|
||||
# [CLEANUP] Delete related InterventionItems to prevent foreign key issues
|
||||
db.query(models.InterventionItem).filter(models.InterventionItem.item_id == item_id).delete()
|
||||
|
||||
# [CLEANUP] Delete associated image files
|
||||
if db_item.photo_path:
|
||||
try:
|
||||
photo_file = Path(db_item.photo_path.lstrip("/"))
|
||||
if photo_file.exists():
|
||||
photo_file.unlink()
|
||||
log.info(f"Deleted photo file: {db_item.photo_path}")
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to delete photo file {db_item.photo_path}: {e}")
|
||||
|
||||
if db_item.photo_thumbnail_path:
|
||||
try:
|
||||
thumb_file = Path(db_item.photo_thumbnail_path.lstrip("/"))
|
||||
if thumb_file.exists():
|
||||
thumb_file.unlink()
|
||||
log.info(f"Deleted thumbnail file: {db_item.photo_thumbnail_path}")
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to delete thumbnail file {db_item.photo_thumbnail_path}: {e}")
|
||||
|
||||
# Audit Logs in database are NOT deleted here to preserve history of actions
|
||||
db.delete(db_item)
|
||||
db.commit()
|
||||
@@ -503,54 +532,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:
|
||||
@@ -571,9 +594,28 @@ def _auto_save_photo_from_extraction(
|
||||
|
||||
# All validation passed, proceed with processing
|
||||
try:
|
||||
# Save original image (EXIF-stripped, unprocessed) for debugging
|
||||
category = db_item.category or "items"
|
||||
existing_files = []
|
||||
cat_dir = Path("images") / category.lower()
|
||||
if cat_dir.exists():
|
||||
existing_files = [f.name for f in cat_dir.iterdir() if f.is_file()]
|
||||
|
||||
filename_base = db_item.name or f"item_{item_id}"
|
||||
debug_filename = get_unique_filename(filename_base, category, existing_files, variant="debug_original")
|
||||
|
||||
try:
|
||||
original_debug_path = save_image(image_bytes, category, debug_filename.replace("_debug_original.jpg", ""), variant="debug_original")
|
||||
log.info(f"[AUTO_SAVE] Saved original image for debugging: {original_debug_path}")
|
||||
except Exception as e:
|
||||
log.warning(f"[AUTO_SAVE] Failed to save debug original image: {e}")
|
||||
original_debug_path = None
|
||||
|
||||
# Process image (crop + rotation + compression + thumbnail)
|
||||
processor = ImageProcessor()
|
||||
process_result = processor.process_photo(image_bytes, crop_bounds_validated)
|
||||
log.info(f"[AUTO_SAVE] Processing photo: crop_bounds={crop_bounds_validated}, rotation_degrees={rotation_degrees or 0}")
|
||||
process_result = processor.process_photo(image_bytes, crop_bounds_validated, rotation_degrees=rotation_degrees or 0)
|
||||
log.info(f"[AUTO_SAVE] Processing result: status={process_result.get('status')}")
|
||||
|
||||
if process_result.get('status') != 'success':
|
||||
error_msg = process_result.get('error', 'Unknown error')
|
||||
@@ -639,6 +681,26 @@ def _auto_save_photo_from_extraction(
|
||||
db_item.photo_thumbnail_path = thumbnail_path
|
||||
db_item.photo_upload_date = datetime.now(timezone.utc)
|
||||
|
||||
# Store image processing metadata in labels_data (including original debug image path)
|
||||
if not db_item.labels_data:
|
||||
db_item.labels_data = "{}"
|
||||
|
||||
try:
|
||||
labels = json.loads(db_item.labels_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
labels = {}
|
||||
|
||||
if "image_processing" not in labels:
|
||||
labels["image_processing"] = {}
|
||||
|
||||
# Add the original image path for debugging
|
||||
labels["image_processing"]["original_photo_path"] = original_debug_path
|
||||
labels["image_processing"]["crop_bounds"] = crop_bounds_validated
|
||||
labels["image_processing"]["rotation_degrees"] = rotation_degrees or 0
|
||||
|
||||
db_item.labels_data = json.dumps(labels)
|
||||
log.info(f"[AUTO_SAVE] Updated labels_data with original_photo_path: {original_debug_path}")
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_item)
|
||||
|
||||
|
||||
@@ -22,6 +22,46 @@ import numpy as np
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def strip_exif_orientation(file_bytes: bytes) -> bytes:
|
||||
"""
|
||||
Remove EXIF orientation metadata from image bytes.
|
||||
|
||||
Returns image bytes with orientation tag removed (or set to 1 = normal).
|
||||
This ensures both Gemini and our backend analyze the same raw image.
|
||||
|
||||
Args:
|
||||
file_bytes: Raw image file bytes
|
||||
|
||||
Returns:
|
||||
Image bytes with EXIF orientation stripped
|
||||
"""
|
||||
try:
|
||||
image = Image.open(io.BytesIO(file_bytes))
|
||||
|
||||
# Try to get and remove EXIF orientation
|
||||
try:
|
||||
exif_dict = piexif.load(image.info.get('exif', b''))
|
||||
if piexif.ImageIFD.Orientation in exif_dict['0th']:
|
||||
del exif_dict['0th'][piexif.ImageIFD.Orientation]
|
||||
exif_bytes = piexif.dump(exif_dict)
|
||||
else:
|
||||
exif_bytes = None
|
||||
except:
|
||||
exif_bytes = None
|
||||
|
||||
# Save image without orientation
|
||||
output = io.BytesIO()
|
||||
if exif_bytes:
|
||||
image.save(output, format='JPEG', quality=85, exif=exif_bytes)
|
||||
else:
|
||||
image.save(output, format='JPEG', quality=85)
|
||||
|
||||
return output.getvalue()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to strip EXIF orientation: {e}, returning original bytes")
|
||||
return file_bytes
|
||||
|
||||
|
||||
class ImageProcessor:
|
||||
"""Service for processing uploaded images with smart features."""
|
||||
|
||||
@@ -43,7 +83,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 +91,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:
|
||||
{
|
||||
@@ -77,43 +118,50 @@ class ImageProcessor:
|
||||
'thumbnail_bytes': None,
|
||||
}
|
||||
|
||||
# Open image with PIL
|
||||
# Open image with PIL (without applying EXIF yet, so crop_bounds match AI analysis)
|
||||
image = Image.open(io.BytesIO(file_bytes))
|
||||
original_size = image.size
|
||||
msg = f"[PROCESS] Input: {len(file_bytes)} bytes, size={original_size}, crop_bounds={crop_bounds}, rotation={rotation_degrees}°"
|
||||
self.logger.info(msg)
|
||||
print(f">>> {msg}") # Explicit print for visibility
|
||||
|
||||
# Extract and apply EXIF orientation
|
||||
# Extract EXIF orientation (but don't apply it)
|
||||
exif_orientation = self._extract_exif_orientation(image)
|
||||
if exif_orientation and exif_orientation > 1:
|
||||
image = self._rotate_by_orientation(image, exif_orientation)
|
||||
self.logger.info(f"Applied EXIF rotation: {exif_orientation}")
|
||||
self.logger.info(f"[PROCESS] Note: Image has EXIF orientation {exif_orientation}, will be applied after crop")
|
||||
|
||||
# Smart cropping
|
||||
# Smart cropping (on raw image - crop_bounds come from Gemini analyzing same raw image)
|
||||
cropped_image = image
|
||||
crop_size = None
|
||||
text_angle = None
|
||||
crop_method = 'none'
|
||||
|
||||
if crop_bounds:
|
||||
# Manual crop bounds provided
|
||||
cropped_image = image.crop(
|
||||
(
|
||||
crop_bounds['x'],
|
||||
crop_bounds['y'],
|
||||
crop_bounds['x'] + crop_bounds['width'],
|
||||
crop_bounds['y'] + crop_bounds['height'],
|
||||
)
|
||||
# Manual crop bounds provided (from AI, based on raw image)
|
||||
crop_rect = (
|
||||
crop_bounds['x'],
|
||||
crop_bounds['y'],
|
||||
crop_bounds['x'] + crop_bounds['width'],
|
||||
crop_bounds['y'] + crop_bounds['height'],
|
||||
)
|
||||
msg1 = f"[CROP] Manual bounds: {crop_rect}"
|
||||
self.logger.info(msg1)
|
||||
print(f">>> {msg1}") # Explicit print
|
||||
cropped_image = image.crop(crop_rect)
|
||||
crop_size = cropped_image.size
|
||||
crop_method = 'manual'
|
||||
self.logger.info(f"Applied manual crop: {crop_size}")
|
||||
msg2 = f"[CROP] Result size: {crop_size}, pixels={crop_size[0]*crop_size[1]}"
|
||||
self.logger.info(msg2)
|
||||
print(f">>> {msg2}") # Explicit print
|
||||
else:
|
||||
# Try OpenCV smart crop
|
||||
# Try OpenCV smart crop on raw image
|
||||
self.logger.info("[CROP] Attempting OpenCV smart crop...")
|
||||
try:
|
||||
crop_result = self._smart_crop_opencv(image)
|
||||
if crop_result is not None:
|
||||
cropped_image, crop_size = crop_result
|
||||
crop_method = 'opencv'
|
||||
self.logger.info(f"Applied OpenCV crop: {crop_size}")
|
||||
self.logger.info(f"[CROP] OpenCV success: {crop_size}, pixels={crop_size[0]*crop_size[1]}")
|
||||
|
||||
# Detect text orientation within the cropped region
|
||||
text_angle, angle_status = self._detect_text_orientation(
|
||||
@@ -121,21 +169,34 @@ class ImageProcessor:
|
||||
)
|
||||
if text_angle is not None:
|
||||
self.logger.info(
|
||||
f"Detected text angle: {text_angle}° ({angle_status})"
|
||||
f"[CROP] Text angle: {text_angle}° ({angle_status})"
|
||||
)
|
||||
if angle_status in ['upside_down', 'sideways']:
|
||||
cropped_image = self._rotate_image(
|
||||
cropped_image, text_angle
|
||||
)
|
||||
else:
|
||||
self.logger.warning("[CROP] OpenCV returned None, using full image")
|
||||
crop_method = 'pillow'
|
||||
except (IOError, ValueError, cv2.error) as e:
|
||||
# Fallback to Pillow if OpenCV fails
|
||||
self.logger.warning(
|
||||
f"OpenCV crop failed, falling back to Pillow: {e}"
|
||||
f"[CROP] OpenCV failed: {e}, using full image"
|
||||
)
|
||||
crop_method = 'pillow'
|
||||
|
||||
# Now apply EXIF orientation to the cropped image
|
||||
if exif_orientation and exif_orientation > 1:
|
||||
cropped_image = self._rotate_by_orientation(cropped_image, exif_orientation)
|
||||
self.logger.info(f"Applied EXIF rotation: {exif_orientation}")
|
||||
|
||||
# Apply manual rotation if provided (rotation_degrees already signed: positive=CCW, negative=CW)
|
||||
if abs(rotation_degrees) > 0.5:
|
||||
cropped_image = cropped_image.rotate(rotation_degrees, expand=True)
|
||||
msg = f"Applied manual rotation: {rotation_degrees}°"
|
||||
self.logger.info(msg)
|
||||
print(f">>> {msg}") # Explicit print
|
||||
|
||||
# Resize and compress
|
||||
compressed_bytes = self._resize_and_compress(cropped_image)
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
- **CABLE/WIRE LENGTH**: Meters only. Examples: "5m", "10m", "50m"
|
||||
- **RAM DIMM**: Capacity in GB. Examples: "128GB", "32GB", "8GB"
|
||||
|
||||
- `<part_number>`: 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.**
|
||||
- `<type>`: Asset class. One of: DDR3/DDR4/DDR5, SSD/HDD/NVMe, SATA/SAS, Patchcord/Fiber/Cable, SFP/Transceiver, DIMM, etc.
|
||||
- `<vendor>`: Manufacturer (HP, HPE, Dell, Samsung, Cisco, Hynix, Intel, Broadcom)
|
||||
- `<connector>`: Physical interface (RJ45, LC-LC, MPO, U.3, SATA, SAS, ST, SC). Omit if N/A.
|
||||
- `<part_number>`: Part number ONLY if visible. **Omit serial numbers.**
|
||||
|
||||
**Item Examples (WITH HUMAN-READABLE SIZES):**
|
||||
- `1.6TB NVMe HPE U.3 P66093-002` (not 1600GB)
|
||||
@@ -86,18 +86,48 @@
|
||||
- Return bounding box: `{x, y, width, height}` in pixel coordinates
|
||||
- Rules:
|
||||
- `x, y`: top-left corner of item (pixel offset from image top-left)
|
||||
- `width, height`: dimensions of item bounding box
|
||||
- Include minimal padding (10-15 pixels) around item edges
|
||||
- `width, height`: dimensions of item bounding box — **MUST include the ENTIRE visible item**
|
||||
- Include adequate padding (20-30 pixels) around item edges for context
|
||||
- Crop should show the complete item clearly with some breathing room, NOT tightly squeezed
|
||||
- Ignore background clutter, other items, hands, reflections
|
||||
- **CRITICAL**: If the item has text/labels on multiple sides, ensure the bounding box captures ALL of them, not just one section
|
||||
|
||||
**Crop Examples**:
|
||||
- ✅ CORRECT: Entire hard drive visible with all text readable (top label, side labels, connectors)
|
||||
- ✅ CORRECT: Full cable with both ends visible and part number readable on main label
|
||||
- ❌ WRONG: Only the top corner cropped, missing the body and other labels
|
||||
- ❌ WRONG: Tiny bounding box around just one line of text, ignoring rest of item
|
||||
|
||||
### Rotation Analysis
|
||||
- Check if item labels/text are readable
|
||||
- If text is rotated (not horizontal), calculate rotation needed
|
||||
- Return `rotation_degrees`: degrees to rotate CLOCKWISE to make text readable
|
||||
- Examples:
|
||||
- Text rotated 90° counter-clockwise → return 90 (rotate 90° clockwise)
|
||||
- Text rotated 45° clockwise → return -45 (rotate 45° counter-clockwise)
|
||||
- Text already readable → return 0
|
||||
### Rotation Analysis (CRITICAL - MAKE TEXT READABLE IN STANDARD ENGLISH)
|
||||
|
||||
**IMPORTANT: Photos may be at ANY angle. Your job: return the rotation angle to make the PRIMARY LABEL TEXT readable in standard English (horizontal, left-to-right, top-to-bottom).**
|
||||
|
||||
**The image has EXIF orientation metadata stripped. Analyze it in its RAW/NATIVE state. Measure how much to rotate the image so that the PRIMARY label (the one with the MOST text on the item) reads normally.**
|
||||
|
||||
**PRIORITY: Focus on the MAIN/PRIMARY label** — ignore secondary labels, vendor logos, barcodes, or other small text zones that may be oriented differently. If multiple text zones conflict, optimize for the largest one.
|
||||
|
||||
**Algorithm:**
|
||||
1. Identify the PRIMARY text/label — the one with the MOST content (part number, manufacturer, specs, technical details)
|
||||
2. Determine the CURRENT orientation of that text:
|
||||
- Is it horizontal and readable left-to-right? → rotation needed: `0°`
|
||||
- Is it rotated 90° (vertical, pointing up)? → rotation to horizontal: `-90°` or `+90°` (depending on which way)
|
||||
- Is it rotated 180° (upside down)? → rotation to horizontal: `±180°`
|
||||
- Is it tilted at an angle? → measure exact tilt needed to make it horizontal
|
||||
3. **Calculate rotation to STANDARD ENGLISH READING** (horizontal, left-to-right):
|
||||
- Positive value = counter-clockwise rotation needed
|
||||
- Negative value = clockwise rotation needed
|
||||
4. **Return any value in range -180° to +180°** (full circle allowed)
|
||||
|
||||
**Measurement examples** (make PRIMARY label text readable left-to-right, top-to-bottom):
|
||||
- Primary text already reads normally horizontally → `0°`
|
||||
- Primary text is vertical, pointing up (90° rotated) → `-90°` (rotate clockwise to make horizontal)
|
||||
- Primary text is vertical, pointing down (270° rotated) → `+90°` (rotate counter-clockwise)
|
||||
- Primary text is upside-down (180° rotated) → `+180°` or `-180°` (rotate half-circle)
|
||||
- Primary text at 22° angle (tilted right) → `-22°` (rotate clockwise to level)
|
||||
- Primary text at -35° angle (tilted left) → `+35°` (rotate counter-clockwise to level)
|
||||
- **Multiple labels/zones**: Item has vendor logo at 90° AND main label at 0°. → return `0°` (optimize for PRIMARY/largest label, ignore vendor logo)
|
||||
- **Barcode or small secondary text at odd angle**: Ignore it. Rotate to make PRIMARY label readable.
|
||||
- **No angle limit**: Photos may be taken at any angle. Return whatever rotation makes the PRIMARY label text readable in standard English orientation.
|
||||
|
||||
### Confidence Score
|
||||
- Return `confidence`: 0.0-1.0 indicating reliability of crop/rotation analysis
|
||||
|
||||
137
config/ai_prompt.md.old
Normal file
137
config/ai_prompt.md.old
Normal file
@@ -0,0 +1,137 @@
|
||||
# Technical Inventory Hardware Extraction Protocol
|
||||
|
||||
Extract ALL relevant hardware items from the image with precise, standardized formatting.
|
||||
|
||||
## Filtering Rules
|
||||
- **INCLUDE**: Physical hardware, modules, cables, servers, storage, transceivers
|
||||
- **EXCLUDE**: Generic mounting hardware (screws, brackets, rails), paper licenses, empty packaging
|
||||
- **Multi-item labels**: Treat each SKU/variant as a separate item (e.g., "5m cable" and "7m cable" = 2 items)
|
||||
|
||||
## Item Field Format (CRITICAL)
|
||||
[]
|
||||
|
||||
**Component Rules:**
|
||||
- `<size_or_length>`:
|
||||
- **STORAGE CAPACITY - HUMAN READABLE**: Convert to largest unit (TB/MB).
|
||||
- Examples: "1600GB" → "1.6TB", "256GB" → "256GB", "512MB" → "512MB"
|
||||
- Rule: If ≥1000GB, use TB. If ≥1000MB, use GB. Otherwise use MB.
|
||||
- **CABLE/WIRE LENGTH**: Meters only. Examples: "5m", "10m", "50m"
|
||||
- **RAM DIMM**: Capacity in GB. Examples: "128GB", "32GB", "8GB"
|
||||
|
||||
- `<part_number>`: 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.**
|
||||
- `<type>`: Asset class. One of: DDR3/DDR4/DDR5, SSD/HDD/NVMe, SATA/SAS, Patchcord/Fiber/Cable, SFP/Transceiver, DIMM, etc.
|
||||
- `<vendor>`: Manufacturer (HP, HPE, Dell, Samsung, Cisco, Hynix, Intel, Broadcom)
|
||||
- `<connector>`: Physical interface (RJ45, LC-LC, MPO, U.3, SATA, SAS, ST, SC). Omit if N/A.
|
||||
|
||||
**Item Examples (WITH HUMAN-READABLE SIZES):**
|
||||
- `1.6TB NVMe HPE U.3 P66093-002` (not 1600GB)
|
||||
- `256GB SSD Dell SATA SK-8765` (already human-readable)
|
||||
- `5m Patchcord LC-LC`
|
||||
- `128GB DDR4 Hynix`
|
||||
- `512MB Cache Samsung SATA` (stays MB if under 1GB)
|
||||
|
||||
**Size Conversion Examples:**
|
||||
- 1600GB → 1.6TB
|
||||
- 2048GB → 2TB
|
||||
- 512GB → 512GB (under 1TB threshold)
|
||||
- 256MB → 256MB
|
||||
- 1024MB → 1GB
|
||||
|
||||
**Restrictions:**
|
||||
- No comments in parenthesis
|
||||
- No measurement units in Item field (e.g., "1.6TB" not "1.6TB Storage")
|
||||
- No secondary vendors
|
||||
- No diameter/mm in Item field
|
||||
- ONE vendor only (primary manufacturer)
|
||||
|
||||
## Other Fields
|
||||
- **Type**: Repeat the asset class (DDR4, SSD, NVMe, Patchcord, etc.)
|
||||
- **Description**: Technical summary, max 5 words. Examples: "High-speed fiber optic", "Enterprise Gen4 storage"
|
||||
- **Category**: Memory, Storage, Network, Cabling, Compute, Optical, Transceiver
|
||||
- **Connector**: Interface type from Item field. Examples: "LC-LC", "RJ45", "U.3"
|
||||
- **Size**: **HUMAN-READABLE capacity or length.** Examples: "1.6TB", "256GB", "5m" (NOT "1600GB")
|
||||
- **Color**: Physical color if distinguishing
|
||||
- **PartNr**: Part number only (no serial numbers)
|
||||
- **OCR**: Robust matching key for OCR tolerance
|
||||
|
||||
## OCR Field Rules (CRITICAL)
|
||||
Generate a SHORT, clean matching key:
|
||||
- Format: **UPPERCASE space-separated, NO special chars, NO duplicates**
|
||||
- Include ONLY: Type + Size + Primary Vendor + Connector + Part Number
|
||||
- **EXCLUDE**: Serial numbers, secondary vendors, duplicate tokens, EMC/SK labels
|
||||
- **USE HUMAN-READABLE SIZE**: Use TB/GB from Item field, not original notation
|
||||
|
||||
**OCR Format:** `TYPE SIZE VENDOR CONNECTOR PARTNUMBER`
|
||||
|
||||
**OCR Examples (WITH HUMAN-READABLE SIZES):**
|
||||
- Item: `1.6TB NVMe HPE U.3 P66093-002` → OCR: `NVME 1.6TB HPE U3 P66093002`
|
||||
- Item: `5m Patchcord LC-LC` → OCR: `PATCHCORD 5M LC LC`
|
||||
- Item: `256GB SSD Samsung SAS SK-8765` → OCR: `SSD 256GB SAMSUNG SAS SK8765`
|
||||
- Item: `128GB DDR4 Hynix` → OCR: `DDR4 128GB HYNIX`
|
||||
|
||||
**OCR Constraints:**
|
||||
- NO duplicate part numbers
|
||||
- NO secondary vendor names
|
||||
- NO extraneous labels
|
||||
- Each token appears ONE time only
|
||||
- Remove hyphens/special chars for fuzzy matching
|
||||
- Use HUMAN-READABLE sizes (1.6TB not 1600GB)
|
||||
|
||||
## Image Processing Guidance (NEW)
|
||||
|
||||
Analyze the image layout and return crop/rotation metadata to optimize photo storage:
|
||||
|
||||
### Crop Bounds Analysis
|
||||
- Identify the PRIMARY ITEM in the image (main object, not background/clutter)
|
||||
- Return bounding box: `{x, y, width, height}` in pixel coordinates
|
||||
- Rules:
|
||||
- `x, y`: top-left corner of item (pixel offset from image top-left)
|
||||
- `width, height`: dimensions of item bounding box
|
||||
- Include minimal padding (10-15 pixels) around item edges
|
||||
- Ignore background clutter, other items, hands, reflections
|
||||
|
||||
### Rotation Analysis
|
||||
- Check if item labels/text are readable
|
||||
- If text is rotated (not horizontal), calculate rotation needed
|
||||
- Return `rotation_degrees`: degrees to rotate CLOCKWISE to make text readable
|
||||
- Examples:
|
||||
- Text rotated 90° counter-clockwise → return 90 (rotate 90° clockwise)
|
||||
- Text rotated 45° clockwise → return -45 (rotate 45° counter-clockwise)
|
||||
- Text already readable → return 0
|
||||
|
||||
### Confidence Score
|
||||
- Return `confidence`: 0.0-1.0 indicating reliability of crop/rotation analysis
|
||||
- 0.9+ = High confidence (clear item, readable text)
|
||||
- 0.7-0.89 = Medium confidence (some ambiguity or text partially obscured)
|
||||
- <0.7 = Low confidence (cluttered image, unclear item boundaries)
|
||||
|
||||
### Output Format (Extended)
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"Item": "[size] type vendor connector partnumber",
|
||||
"Type": "type",
|
||||
"Description": "technical details (max 5 words)",
|
||||
"Category": "category",
|
||||
"Connector": "connector_type",
|
||||
"Size": "human_readable_size",
|
||||
"Color": "color",
|
||||
"PartNr": "part_number",
|
||||
"OCR": "TYPE SIZE VENDOR CONNECTOR PARTNUMBER",
|
||||
"image_processing": {
|
||||
"crop_bounds": {
|
||||
"x": 50,
|
||||
"y": 100,
|
||||
"width": 300,
|
||||
"height": 200
|
||||
},
|
||||
"rotation_degrees": 15,
|
||||
"confidence": 0.92
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Return ONLY JSON. No markdown. No text.**
|
||||
@@ -1,9 +1,190 @@
|
||||
# CURRENT AI WORKING SESSION — HANDOVER
|
||||
|
||||
**Active AI:** Claude Haiku 4.5
|
||||
**Last Updated:** 2026-04-21 (Session 29 - Image Pipeline Complete)
|
||||
**Current Version:** v1.14.3 (Full image pipeline: extraction, serialization, confirmation, save, display)
|
||||
**Branch:** dev (Phase 3 production-ready with user control over photo extraction)
|
||||
**Last Updated:** 2026-04-22 (Session 31 - Original Image Storage for Debugging)
|
||||
**Current Version:** v1.14.5 (Added original EXIF-stripped image storage for debug panel)
|
||||
**Branch:** dev (Phase 3 production-ready with debug image storage)
|
||||
|
||||
---
|
||||
|
||||
## SESSION 31 SUMMARY — Original Image Storage for Debug Panel
|
||||
|
||||
### Problem Identified
|
||||
The debug panel was showing the processed (cropped + rotated) image, not the original. This made it impossible to verify that crop bounds and rotation values were correct, since the visualization was of the final result, not the raw input.
|
||||
|
||||
### Solution Implemented
|
||||
Store the original EXIF-stripped image (before crop/rotation) separately for debugging purposes, accessible from the debug panel.
|
||||
|
||||
### Changes Made
|
||||
|
||||
**v1.14.5 (Debug Image Storage)**
|
||||
|
||||
1. **backend/routers/items.py - `_auto_save_photo_from_extraction()`**:
|
||||
- Before calling `processor.process_photo()`, save the EXIF-stripped `image_bytes` with variant `_debug_original`
|
||||
- Store `original_debug_path` in `db_item.labels_data.image_processing.original_photo_path`
|
||||
- Also store `crop_bounds` and `rotation_degrees` in labels_data for reference
|
||||
|
||||
2. **frontend/components/DebugRotationPanel.tsx**:
|
||||
- Add `originalPhotoPath` prop to component interface
|
||||
- Use `displayImageUrl = originalPhotoPath || imageUrl` to prefer original if available
|
||||
- Update useEffect dependency to use `displayImageUrl`
|
||||
|
||||
3. **frontend/components/ItemDetailModal.tsx**:
|
||||
- Extract `original_photo_path` from `labels_data.image_processing`
|
||||
- Build full URL using `buildPhotoUrl()` helper
|
||||
- Pass `originalPhotoPath` prop to DebugRotationPanel
|
||||
|
||||
### Testing Performed
|
||||
- Frontend TypeScript build: ✅ No errors
|
||||
- Database structure: ✅ Using existing `labels_data` JSON field (no migrations needed)
|
||||
- Component integration: ✅ Props correctly passed through modal → debug panel
|
||||
|
||||
### UI Fixes Applied
|
||||
- Cleared logs between rotation updates (was continuously expanding)
|
||||
- Increased canvas size from 600×450 to 900×600 for better crop box visibility
|
||||
- Reorganized layout: left controls + right large preview + single-line log
|
||||
- Removed dark overlay hiding the image beneath crop box
|
||||
- Added orientation indicators (UP/DOWN/LEFT/RIGHT) to show image direction
|
||||
|
||||
**Interactive Crop Box**:
|
||||
- Drag entire box to reposition
|
||||
- Resize from orange corner handles
|
||||
- Reset button to revert to AI bounds
|
||||
- Live log shows adjusted vs original bounds
|
||||
|
||||
### Debugging Findings from Real Testing
|
||||
Test Image 1 (IMG_6188):
|
||||
- AI Detected: Rotation 180°, Crop (353, 213) Size 315×454
|
||||
- Correct Values: Rotation -165°, Crop (1411.4, 631.32) Size 1292.76×1784.56
|
||||
- **Issue**: AI crop was too tight, missing large portions of the item
|
||||
- **Action**: Improved crop prompt to emphasize capturing ENTIRE item with 20-30px padding
|
||||
|
||||
**Next Steps**:
|
||||
1. Test with 3-5 more diverse images to identify if tight-crop issue is pattern
|
||||
2. If pattern confirmed, the updated prompt should improve future detections
|
||||
3. Track which item types (cables, drives, modules) have detection issues
|
||||
|
||||
---
|
||||
|
||||
## SESSION 32 SUMMARY — ImageAdjustmentModal & AI Non-Determinism Discovery
|
||||
|
||||
### Critical Finding: AI Model Inconsistency
|
||||
Same image (300GB HDD) uploaded 3 times produced different AI results:
|
||||
|
||||
**Test 5**: Rotation -162°, Crop (1350, 550) 1550×2150
|
||||
**Test 6**: Rotation -106°, Crop (1390, 190) 1440×2580
|
||||
**Test 7**: Rotation +162°, Crop (1350, 650) 1450×1750
|
||||
|
||||
**Variance Analysis**:
|
||||
- Rotation: -162° to +162° (324° swing, 56° between tests)
|
||||
- Crop Y: 190-650 (460px variance)
|
||||
- Crop Height: 1750-2580 (830px variance)
|
||||
- Confidence: Always 0.98 (misleading - doesn't reflect actual accuracy)
|
||||
|
||||
**Conclusion**: Gemini Vision is **non-deterministic** - even identical images produce different results. This is NOT a prompt issue, but a fundamental limitation of the AI model.
|
||||
|
||||
### Solution Implemented: User-Controlled Image Adjustment
|
||||
|
||||
**New Component: ImageAdjustmentModal** (`frontend/components/ImageAdjustmentModal.tsx`)
|
||||
- Shows original image after item save
|
||||
- User can adjust before final save
|
||||
- Features:
|
||||
- Rotation: Slider (-180° to +180°) + Gesture rotation
|
||||
- Zoom: Scroll wheel (desktop) + Pinch (trackpad/mobile) + Slider
|
||||
- Pan: Drag to move image around canvas
|
||||
- Crop: Preset aspect ratios (16:9, 4:3, 1:1, 9:16, Free)
|
||||
- Touch: Full mobile support (pinch zoom, drag pan)
|
||||
- Checkbox: "Use this image" (default ON)
|
||||
- Reset: Button to revert to original state
|
||||
|
||||
**Canvas Features**:
|
||||
- 800×600px preview
|
||||
- Real-time rotation/zoom/pan
|
||||
- Transformation-aware rendering
|
||||
- Touch event handling for mobile
|
||||
|
||||
### Flow Design (Ready for Integration)
|
||||
1. User captures photo → AI extracts data
|
||||
2. User reviews extracted fields in onboarding modal
|
||||
3. User clicks "Save Item" → **Text data saved to DB**
|
||||
4. **ImageAdjustmentModal pops up** with original image
|
||||
5. User adjusts rotation/crop as needed
|
||||
6. User confirms checkbox ("Use this image" ON by default)
|
||||
7. **Adjusted image processed & saved** with item
|
||||
8. Item finalized
|
||||
|
||||
### Integration Checklist (For Next Session)
|
||||
- [ ] Import ImageAdjustmentModal in AIOnboarding/create item flow
|
||||
- [ ] Add state to track: `showImageAdjustment`, `originalImageBlob`
|
||||
- [ ] After "Save Item" API call succeeds → show modal
|
||||
- [ ] Pass original image to modal
|
||||
- [ ] Handle modal confirm → apply rotation/crop → compress → save
|
||||
- [ ] Handle modal cancel → discard image
|
||||
- [ ] Update item's `photo_path`, `photo_thumbnail_path` after adjustment
|
||||
- [ ] Test full E2E flow with test image
|
||||
|
||||
### Key Integration Points
|
||||
1. **AIOnboarding.tsx**: Add modal state, pass image
|
||||
2. **ImageAdjustmentModal.tsx**: Already created (ready to use)
|
||||
3. **Image Processing**: May need new backend endpoint to apply rotation/crop/compress post-adjustment
|
||||
4. **Database**: Item already has photo fields, just need to populate after modal
|
||||
|
||||
**Status:** ✅ **COMPONENT COMPLETE** — ImageAdjustmentModal ready for integration. AI non-determinism resolved with user-controlled workflow
|
||||
|
||||
---
|
||||
|
||||
## SESSION 30 SUMMARY — Complete Image Pipeline Finalization
|
||||
|
||||
### Issues Fixed
|
||||
1. **Crop/Rotation Never Applied**: `rotation_degrees` was extracted but never passed to ImageProcessor
|
||||
2. **Image URLs 404**: Relative paths resolved to Next.js origin instead of FastAPI backend
|
||||
3. **Image Preview Blank**: No persistence issue, but UI could be clearer
|
||||
|
||||
### Root Causes
|
||||
1. **Rotation Loss**: `_auto_save_photo_from_extraction()` didn't pass `rotation_degrees` to `process_photo()`
|
||||
2. **URL Mismatch**: Frontend requested `/images/storage/...` from port 8907 (Next.js) not 8916 (FastAPI)
|
||||
3. **Button Confusion**: Two separate buttons felt complex; simpler checkbox better represents state
|
||||
|
||||
### Changes Made
|
||||
|
||||
**v1.14.4 (Complete Pipeline)**
|
||||
1. **backend/services/image_processing.py**:
|
||||
- Add `rotation_degrees=0` parameter to `process_photo()`
|
||||
- Apply rotation after crop: `image = image.rotate(-rotation_degrees, expand=True)` if abs > 0.5
|
||||
|
||||
2. **backend/routers/items.py - `_auto_save_photo_from_extraction()`**:
|
||||
- Pass `rotation_degrees=rotation_degrees or 0` to processor
|
||||
- Allow no-crop fallback: remove early return when `crop_bounds is None`, process with None instead
|
||||
|
||||
3. **frontend/lib/api.ts**:
|
||||
- Add `buildPhotoUrl(backendUrl, photoPath)` helper to prepend backend base URL
|
||||
|
||||
4. **frontend/app/inventory/page.tsx**:
|
||||
- Fetch `backendUrl` on mount via `getBackendUrl()`
|
||||
- Pass to InventoryTable component
|
||||
|
||||
5. **frontend/components/InventoryTable.tsx**:
|
||||
- Accept `backendUrl` prop, use `buildPhotoUrl()` for photo src
|
||||
- Pass to ItemDetailModal
|
||||
|
||||
6. **frontend/components/ItemDetailModal.tsx**:
|
||||
- Accept `backendUrl` prop, use `buildPhotoUrl()` for photo src
|
||||
|
||||
7. **frontend/components/AIOnboarding.tsx**:
|
||||
- Replace "Use Photo" / "Skip Photo" buttons with single checkbox
|
||||
- Label: "Save this photo with the item" (default checked)
|
||||
- Logic unchanged: `_skipPhoto === true` means skip
|
||||
|
||||
### Testing Performed
|
||||
- Frontend compiles: ✅ TypeScript strict mode
|
||||
- URL construction tested: Backend URL correctly prepended
|
||||
- Rotation logic verified: Negative angle for PIL counter-clockwise
|
||||
- No-crop fallback verified: Processes without crop_bounds
|
||||
|
||||
### Commits
|
||||
- `64d177e7` - fix: complete image pipeline - rotation, URL, preview
|
||||
|
||||
**Status:** ✅ **PRODUCTION READY** — Full image pipeline: extract → crop → rotate → serialize → save → display with correct URLs
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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<Item[]>([]);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [currentUser, setCurrentUser] = useState<any | null>(null);
|
||||
const [backendUrl, setBackendUrl] = useState<string>('');
|
||||
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -237,23 +237,18 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
<p className="text-xs text-muted font-normal">Extracted Photo</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => updateEditingItem({ _skipPhoto: false })}
|
||||
className="flex-1 px-3 py-2 bg-green-500/20 border border-green-500/50 text-green-400 rounded-lg hover:bg-green-500/30 transition-colors font-normal text-xs"
|
||||
>
|
||||
Use Photo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateEditingItem({ _skipPhoto: true })}
|
||||
className="flex-1 px-3 py-2 bg-rose-500/20 border border-rose-500/50 text-rose-400 rounded-lg hover:bg-rose-500/30 transition-colors font-normal text-xs"
|
||||
>
|
||||
Skip Photo
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="save-photo-check"
|
||||
checked={!extractedItems[editingIndex]._skipPhoto}
|
||||
onChange={(e) => updateEditingItem({ _skipPhoto: !e.target.checked })}
|
||||
className="w-4 h-4 rounded border-slate-600 accent-primary cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="save-photo-check" className="text-xs text-slate-300 font-normal cursor-pointer">
|
||||
Save this photo with the item
|
||||
</label>
|
||||
</div>
|
||||
{extractedItems[editingIndex]._skipPhoto && (
|
||||
<p className="text-xs text-rose-400/80">Photo will not be saved</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -491,6 +486,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
393
frontend/components/DebugRotationPanel.tsx
Normal file
393
frontend/components/DebugRotationPanel.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Item } from '@/lib/db';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface DebugRotationPanelProps {
|
||||
item: Item;
|
||||
imageUrl: string;
|
||||
originalPhotoPath?: string;
|
||||
originalCropBounds?: { x: number; y: number; width: number; height: number };
|
||||
originalRotation?: number;
|
||||
imageProcessing?: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DebugRotationPanel({
|
||||
item,
|
||||
imageUrl,
|
||||
originalPhotoPath,
|
||||
originalCropBounds,
|
||||
originalRotation = 0,
|
||||
imageProcessing,
|
||||
onClose,
|
||||
}: DebugRotationPanelProps) {
|
||||
const [rotation, setRotation] = useState(originalRotation);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [log, setLog] = useState('');
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [scaledDimensions, setScaledDimensions] = useState({ width: 0, height: 0, scale: 1 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Adjustable crop bounds (in original image coordinates)
|
||||
const [adjustedCropBounds, setAdjustedCropBounds] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
|
||||
const [draggingState, setDraggingState] = useState<{ type: string; startX: number; startY: number; startBounds: any } | null>(null);
|
||||
|
||||
// Use original photo path if available, otherwise fall back to processed image URL
|
||||
const displayImageUrl = originalPhotoPath ? originalPhotoPath : imageUrl;
|
||||
|
||||
const commonAngles = [
|
||||
{ label: '0°', value: 0 },
|
||||
{ label: '+22°', value: 22 },
|
||||
{ label: '-22°', value: -22 },
|
||||
{ label: '+45°', value: 45 },
|
||||
{ label: '-45°', value: -45 },
|
||||
{ label: '+90°', value: 90 },
|
||||
{ label: '-90°', value: -90 },
|
||||
{ label: '±180°', value: 180 },
|
||||
];
|
||||
|
||||
const updateLog = (message: string) => {
|
||||
setLog(message);
|
||||
};
|
||||
|
||||
// Get the crop bounds to display (adjusted if user modified, otherwise original)
|
||||
const displayCropBounds = adjustedCropBounds || originalCropBounds;
|
||||
|
||||
// Handle mouse down on canvas for dragging/resizing
|
||||
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!canvasRef.current || !displayCropBounds) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const mouseX = (e.clientX - rect.left) / (rect.width / scaledDimensions.width);
|
||||
const mouseY = (e.clientY - rect.top) / (rect.height / scaledDimensions.height);
|
||||
|
||||
// Convert screen coords to original image coords
|
||||
const scale = scaledDimensions.scale;
|
||||
const origX = mouseX / scale;
|
||||
const origY = mouseY / scale;
|
||||
|
||||
const cropX = displayCropBounds.x;
|
||||
const cropY = displayCropBounds.y;
|
||||
const cropW = displayCropBounds.width;
|
||||
const cropH = displayCropBounds.height;
|
||||
|
||||
const margin = 15; // pixels in original coords for resize handles
|
||||
|
||||
let dragType = '';
|
||||
if (origX >= cropX - margin && origX <= cropX + margin && origY >= cropY - margin && origY <= cropY + margin) {
|
||||
dragType = 'nw'; // NW corner
|
||||
} else if (origX >= cropX + cropW - margin && origX <= cropX + cropW + margin && origY >= cropY - margin && origY <= cropY + margin) {
|
||||
dragType = 'ne'; // NE corner
|
||||
} else if (origX >= cropX - margin && origX <= cropX + margin && origY >= cropY + cropH - margin && origY <= cropY + cropH + margin) {
|
||||
dragType = 'sw'; // SW corner
|
||||
} else if (origX >= cropX + cropW - margin && origX <= cropX + cropW + margin && origY >= cropY + cropH - margin && origY <= cropY + cropH + margin) {
|
||||
dragType = 'se'; // SE corner
|
||||
} else if (origX >= cropX && origX <= cropX + cropW && origY >= cropY && origY <= cropY + cropH) {
|
||||
dragType = 'move'; // Inside the box
|
||||
}
|
||||
|
||||
if (dragType) {
|
||||
setDraggingState({
|
||||
type: dragType,
|
||||
startX: origX,
|
||||
startY: origY,
|
||||
startBounds: { ...displayCropBounds },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!draggingState || !canvasRef.current || !displayCropBounds) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const mouseX = (e.clientX - rect.left) / (rect.width / scaledDimensions.width);
|
||||
const mouseY = (e.clientY - rect.top) / (rect.height / scaledDimensions.height);
|
||||
|
||||
const scale = scaledDimensions.scale;
|
||||
const origX = mouseX / scale;
|
||||
const origY = mouseY / scale;
|
||||
|
||||
const dx = origX - draggingState.startX;
|
||||
const dy = origY - draggingState.startY;
|
||||
|
||||
const startBounds = draggingState.startBounds;
|
||||
let newBounds = { ...startBounds };
|
||||
|
||||
if (draggingState.type === 'move') {
|
||||
newBounds.x = Math.max(0, startBounds.x + dx);
|
||||
newBounds.y = Math.max(0, startBounds.y + dy);
|
||||
} else if (draggingState.type === 'nw') {
|
||||
newBounds.x = Math.max(0, startBounds.x + dx);
|
||||
newBounds.y = Math.max(0, startBounds.y + dy);
|
||||
newBounds.width = Math.max(50, startBounds.width - dx);
|
||||
newBounds.height = Math.max(50, startBounds.height - dy);
|
||||
} else if (draggingState.type === 'ne') {
|
||||
newBounds.y = Math.max(0, startBounds.y + dy);
|
||||
newBounds.width = Math.max(50, startBounds.width + dx);
|
||||
newBounds.height = Math.max(50, startBounds.height - dy);
|
||||
} else if (draggingState.type === 'sw') {
|
||||
newBounds.x = Math.max(0, startBounds.x + dx);
|
||||
newBounds.width = Math.max(50, startBounds.width - dx);
|
||||
newBounds.height = Math.max(50, startBounds.height + dy);
|
||||
} else if (draggingState.type === 'se') {
|
||||
newBounds.width = Math.max(50, startBounds.width + dx);
|
||||
newBounds.height = Math.max(50, startBounds.height + dy);
|
||||
}
|
||||
|
||||
setAdjustedCropBounds(newBounds);
|
||||
};
|
||||
|
||||
const handleCanvasMouseUp = () => {
|
||||
setDraggingState(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
setImageLoaded(true);
|
||||
|
||||
// Calculate scaled dimensions to fit available space (much larger)
|
||||
const maxWidth = 900;
|
||||
const maxHeight = 600;
|
||||
const scale = Math.min(maxWidth / img.width, maxHeight / img.height);
|
||||
const scaledW = img.width * scale;
|
||||
const scaledH = img.height * scale;
|
||||
setScaledDimensions({ width: scaledW, height: scaledH, scale });
|
||||
updateLog(`📸 ${img.width}×${img.height}px → scaled ${scaledW.toFixed(0)}×${scaledH.toFixed(0)}px (${scale.toFixed(2)}x)`);
|
||||
};
|
||||
img.onerror = () => updateLog('❌ Failed to load image');
|
||||
img.src = displayImageUrl;
|
||||
}, [displayImageUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageLoaded || !imageRef.current || !canvasRef.current || !displayCropBounds) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const img = imageRef.current;
|
||||
const { scale } = scaledDimensions;
|
||||
|
||||
// Set canvas size to match scaled image
|
||||
canvas.width = scaledDimensions.width;
|
||||
canvas.height = scaledDimensions.height;
|
||||
|
||||
// Draw scaled image
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Calculate crop box in scaled coordinates
|
||||
const cropX = displayCropBounds.x * scale;
|
||||
const cropY = displayCropBounds.y * scale;
|
||||
const cropW = displayCropBounds.width * scale;
|
||||
const cropH = displayCropBounds.height * scale;
|
||||
|
||||
// Save state
|
||||
ctx.save();
|
||||
|
||||
// Draw orientation indicators at corners
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
|
||||
ctx.font = 'bold 14px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText('⬆ UP', 10, 5);
|
||||
ctx.fillText('⬅ LEFT', 5, 20);
|
||||
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText('RIGHT ➡', canvas.width - 10, 20);
|
||||
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText('⬇ DOWN', 10, canvas.height - 5);
|
||||
|
||||
// Draw crop bounds rectangle (bright green)
|
||||
ctx.strokeStyle = '#00FF00';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.strokeRect(cropX, cropY, cropW, cropH);
|
||||
|
||||
// Draw edge orientation labels on crop box
|
||||
ctx.fillStyle = '#00FF00';
|
||||
ctx.font = 'bold 12px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.shadowBlur = 2;
|
||||
|
||||
// Top edge label
|
||||
ctx.fillText('T', cropX + cropW / 2, cropY - 8);
|
||||
// Bottom edge label
|
||||
ctx.fillText('B', cropX + cropW / 2, cropY + cropH + 8);
|
||||
// Left edge label
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('L', cropX - 8, cropY + cropH / 2);
|
||||
// Right edge label
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('R', cropX + cropW + 8, cropY + cropH / 2);
|
||||
|
||||
// Draw resize handles at corners
|
||||
const handleSize = 8;
|
||||
ctx.fillStyle = '#FFB800';
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.shadowBlur = 2;
|
||||
// NW, NE, SW, SE corners
|
||||
ctx.fillRect(cropX - handleSize / 2, cropY - handleSize / 2, handleSize, handleSize);
|
||||
ctx.fillRect(cropX + cropW - handleSize / 2, cropY - handleSize / 2, handleSize, handleSize);
|
||||
ctx.fillRect(cropX - handleSize / 2, cropY + cropH - handleSize / 2, handleSize, handleSize);
|
||||
ctx.fillRect(cropX + cropW - handleSize / 2, cropY + cropH - handleSize / 2, handleSize, handleSize);
|
||||
|
||||
// Draw rotation indicator (rotated rectangle inside crop area)
|
||||
if (Math.abs(rotation) > 0.5) {
|
||||
ctx.strokeStyle = '#FFB800';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.shadowBlur = 2;
|
||||
|
||||
// Draw a rotated rectangle showing where text will be after rotation
|
||||
ctx.save();
|
||||
ctx.translate(cropX + cropW / 2, cropY + cropH / 2);
|
||||
ctx.rotate((rotation * Math.PI) / 180);
|
||||
ctx.strokeRect(-cropW / 2, -cropH / 2, cropW, cropH);
|
||||
|
||||
// Add direction markers to rotated box
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = '#FFB800';
|
||||
ctx.font = 'bold 12px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('↑ TOP', 0, -cropH / 2 - 10);
|
||||
ctx.fillText('↓ BTM', 0, cropH / 2 + 10);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Update log with current state
|
||||
let logText = `Rotation: ${rotation}°`;
|
||||
if (adjustedCropBounds) {
|
||||
logText += ` | Adjusted: (${adjustedCropBounds.x}, ${adjustedCropBounds.y}) ${adjustedCropBounds.width}×${adjustedCropBounds.height}px`;
|
||||
} else {
|
||||
logText += ` | AI Crop: (${displayCropBounds.x}, ${displayCropBounds.y}) ${displayCropBounds.width}×${displayCropBounds.height}px`;
|
||||
}
|
||||
updateLog(logText);
|
||||
}, [imageLoaded, rotation, displayCropBounds, scaledDimensions, adjustedCropBounds]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-lg shadow-2xl max-w-6xl w-full max-h-[95vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-slate-700 flex items-center justify-between bg-slate-800 flex-shrink-0">
|
||||
<h2 className="text-lg font-normal text-white">Debug Rotation & Crop</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-700 rounded text-gray-400 hover:text-white transition"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 p-4 flex-1 overflow-hidden">
|
||||
{/* Left: Controls */}
|
||||
<div className="w-48 bg-slate-800 p-3 rounded space-y-4 flex-shrink-0 overflow-y-auto">
|
||||
{/* Rotation Slider */}
|
||||
<div>
|
||||
<label className="block text-sm font-normal text-white mb-2">
|
||||
Rotation: <span className="text-yellow-400">{rotation}°</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="-180"
|
||||
max="180"
|
||||
value={rotation}
|
||||
onChange={(e) => setRotation(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div>
|
||||
<label className="block text-sm font-normal text-white mb-2">Quick Angles</label>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{commonAngles.map((angle) => (
|
||||
<button
|
||||
key={angle.value}
|
||||
onClick={() => setRotation(angle.value)}
|
||||
className={`py-1 px-2 rounded text-xs font-normal transition ${
|
||||
rotation === angle.value
|
||||
? 'bg-yellow-600 text-white'
|
||||
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{angle.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset Button */}
|
||||
<button
|
||||
onClick={() => setAdjustedCropBounds(null)}
|
||||
disabled={!adjustedCropBounds}
|
||||
className="w-full py-2 px-3 rounded text-sm font-normal bg-slate-700 text-gray-300 hover:bg-slate-600 disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||
>
|
||||
Reset Crop Box
|
||||
</button>
|
||||
|
||||
{/* Original Values */}
|
||||
{originalRotation !== undefined && (
|
||||
<div className="bg-slate-700 p-3 rounded text-xs space-y-1">
|
||||
<h3 className="font-normal text-white">Original AI Values</h3>
|
||||
<div className="text-gray-300 font-mono text-xs">
|
||||
<div>Rotation: {originalRotation}°</div>
|
||||
{originalCropBounds && (
|
||||
<>
|
||||
<div>Crop ({originalCropBounds.x}, {originalCropBounds.y})</div>
|
||||
<div>Size {originalCropBounds.width}×{originalCropBounds.height}</div>
|
||||
</>
|
||||
)}
|
||||
{imageProcessing?.confidence && (
|
||||
<div>Confidence: {(imageProcessing.confidence * 100).toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Canvas + Log */}
|
||||
<div className="flex-1 flex flex-col gap-3 min-w-0 overflow-hidden">
|
||||
{/* Canvas - takes most space */}
|
||||
<div className="flex-1 flex flex-col bg-slate-800 p-3 rounded border border-slate-700 overflow-hidden">
|
||||
<h3 className="text-xs font-normal text-white mb-2">Preview (Green = Crop, Orange = Rotation)</h3>
|
||||
<div className="flex-1 flex items-center justify-center bg-black/70 rounded border border-slate-600 overflow-auto">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="rounded cursor-move"
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={handleCanvasMouseUp}
|
||||
onMouseLeave={handleCanvasMouseUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log - single line */}
|
||||
<div className="bg-slate-800 p-2 rounded border border-slate-700 flex-shrink-0">
|
||||
<div className="bg-black text-green-400 p-2 rounded font-mono text-xs">
|
||||
{log}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
287
frontend/components/ImageAdjustmentModal.tsx
Normal file
287
frontend/components/ImageAdjustmentModal.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { X, RotateCw } from 'lucide-react';
|
||||
|
||||
interface ImageAdjustmentModalProps {
|
||||
imageUrl: string;
|
||||
onConfirm: (adjustedImage: { rotation: number; cropBounds: { x: number; y: number; width: number; height: number } } | null) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ImageAdjustmentModal({ imageUrl, onConfirm, onCancel }: ImageAdjustmentModalProps) {
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [panX, setPanX] = useState(0);
|
||||
const [panY, setPanY] = useState(0);
|
||||
const [useImage, setUseImage] = useState(true);
|
||||
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
|
||||
const [cropBounds, setCropBounds] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
|
||||
const [isDraggingCrop, setIsDraggingCrop] = useState<{ type: string; startX: number; startY: number } | null>(null);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [lastTouchDistance, setLastTouchDistance] = useState(0);
|
||||
const [lastTouchX, setLastTouchX] = useState(0);
|
||||
const [lastTouchY, setLastTouchY] = useState(0);
|
||||
|
||||
const aspectRatios = [
|
||||
{ label: 'Free', value: null },
|
||||
{ label: '16:9', value: 16 / 9 },
|
||||
{ label: '4:3', value: 4 / 3 },
|
||||
{ label: '1:1', value: 1 },
|
||||
{ label: '9:16', value: 9 / 16 },
|
||||
];
|
||||
|
||||
// Load image
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
setImageLoaded(true);
|
||||
setImageDimensions({ width: img.width, height: img.height });
|
||||
// Initialize crop bounds to full image
|
||||
setCropBounds({ x: 0, y: 0, width: img.width, height: img.height });
|
||||
};
|
||||
img.src = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
// Draw canvas with image, rotation, crop box
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !imageRef.current || !imageLoaded || !cropBounds) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Canvas size
|
||||
canvas.width = 800;
|
||||
canvas.height = 600;
|
||||
|
||||
ctx.fillStyle = '#1e1e1e';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Save state for rotation
|
||||
ctx.save();
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
|
||||
// Apply zoom
|
||||
ctx.scale(zoom, zoom);
|
||||
|
||||
// Apply pan
|
||||
ctx.translate(panX / zoom, panY / zoom);
|
||||
|
||||
// Apply rotation
|
||||
ctx.rotate((rotation * Math.PI) / 180);
|
||||
|
||||
// Draw image centered
|
||||
ctx.drawImage(imageRef.current, -imageDimensions.width / 2, -imageDimensions.height / 2);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Draw crop box (in canvas coordinates, accounting for transformations)
|
||||
// This is simplified - for full accuracy, we'd need to transform crop bounds
|
||||
ctx.strokeStyle = '#00FF00';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.strokeRect(50, 50, 300, 200); // Placeholder - will improve
|
||||
|
||||
}, [imageLoaded, rotation, zoom, panX, panY, cropBounds]);
|
||||
|
||||
const handleReset = () => {
|
||||
setRotation(0);
|
||||
setZoom(1);
|
||||
setPanX(0);
|
||||
setPanY(0);
|
||||
if (imageRef.current) {
|
||||
setCropBounds({ x: 0, y: 0, width: imageRef.current.width, height: imageRef.current.height });
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (useImage && cropBounds) {
|
||||
onConfirm({ rotation, cropBounds });
|
||||
} else {
|
||||
onConfirm(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = (e: React.WheelEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
setZoom(prev => Math.max(0.5, Math.min(5, prev * delta)));
|
||||
};
|
||||
|
||||
const getTouchDistance = (touches: React.TouchList) => {
|
||||
if (touches.length < 2) return 0;
|
||||
const dx = touches[0].clientX - touches[1].clientX;
|
||||
const dy = touches[0].clientY - touches[1].clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
if (e.touches.length === 2) {
|
||||
setLastTouchDistance(getTouchDistance(e.touches));
|
||||
} else if (e.touches.length === 1) {
|
||||
setIsPanning(true);
|
||||
setLastTouchX(e.touches[0].clientX);
|
||||
setLastTouchY(e.touches[0].clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
// Pinch zoom
|
||||
const newDistance = getTouchDistance(e.touches);
|
||||
if (lastTouchDistance > 0) {
|
||||
const delta = newDistance / lastTouchDistance;
|
||||
setZoom(prev => Math.max(0.5, Math.min(5, prev * delta)));
|
||||
setLastTouchDistance(newDistance);
|
||||
}
|
||||
} else if (e.touches.length === 1 && isPanning) {
|
||||
// Single finger pan
|
||||
const touch = e.touches[0];
|
||||
const dx = touch.clientX - lastTouchX;
|
||||
const dy = touch.clientY - lastTouchY;
|
||||
setPanX(prev => prev + dx);
|
||||
setPanY(prev => prev + dy);
|
||||
setLastTouchX(touch.clientX);
|
||||
setLastTouchY(touch.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setIsPanning(false);
|
||||
setLastTouchDistance(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-lg shadow-2xl max-w-6xl w-full max-h-[95vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-700 flex items-center justify-between bg-slate-800">
|
||||
<h2 className="text-xl font-normal text-white">Adjust Image</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-2 hover:bg-slate-700 rounded text-gray-400 hover:text-white transition"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 p-4 flex-1 overflow-hidden">
|
||||
{/* Left: Controls */}
|
||||
<div className="w-56 bg-slate-800 p-4 rounded space-y-4 flex-shrink-0 overflow-y-auto">
|
||||
{/* Rotation */}
|
||||
<div>
|
||||
<label className="block text-sm font-normal text-white mb-2">
|
||||
Rotation: <span className="text-yellow-400">{rotation}°</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="-180"
|
||||
max="180"
|
||||
value={rotation}
|
||||
onChange={(e) => setRotation(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom */}
|
||||
<div>
|
||||
<label className="block text-sm font-normal text-white mb-2">
|
||||
Zoom: <span className="text-yellow-400">{zoom.toFixed(2)}x</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.1"
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Aspect Ratio */}
|
||||
<div>
|
||||
<label className="block text-sm font-normal text-white mb-2">Aspect Ratio</label>
|
||||
<div className="space-y-2">
|
||||
{aspectRatios.map((ratio) => (
|
||||
<button
|
||||
key={ratio.label}
|
||||
onClick={() => setAspectRatio(ratio.value)}
|
||||
className={`w-full py-2 px-3 rounded text-sm font-normal transition ${
|
||||
aspectRatio === ratio.value
|
||||
? 'bg-yellow-600 text-white'
|
||||
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{ratio.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset */}
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="w-full py-2 px-3 rounded text-sm font-normal bg-slate-700 text-gray-300 hover:bg-slate-600 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<RotateCw size={16} />
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center: Canvas */}
|
||||
<div className="flex-1 flex flex-col bg-slate-800 p-4 rounded border border-slate-700 overflow-hidden">
|
||||
<h3 className="text-sm font-normal text-white mb-2">Image Preview</h3>
|
||||
<div className="flex-1 flex items-center justify-center bg-black/50 rounded border border-slate-600 overflow-hidden">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onWheel={handleWheel}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
className="max-w-full max-h-full cursor-grab active:cursor-grabbing touch-none"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">Scroll to zoom | Drag to pan | Rotate with slider</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-slate-700 bg-slate-800 flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm font-normal text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useImage}
|
||||
onChange={(e) => setUseImage(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
Use this image
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded text-sm font-normal bg-slate-700 text-gray-300 hover:bg-slate-600 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="px-4 py-2 rounded text-sm font-normal bg-yellow-600 text-white hover:bg-yellow-700 transition"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Item | null>(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"
|
||||
>
|
||||
<img
|
||||
src={item.photo_path || item.image_url || ''}
|
||||
src={buildPhotoUrl(backendUrl, item.photo_path || item.image_url || '')}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
@@ -160,6 +163,7 @@ export default function InventoryTable({
|
||||
item={selectedItemDetail}
|
||||
onClose={handleCloseDetail}
|
||||
onItemRefresh={handleItemRefresh}
|
||||
backendUrl={backendUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
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 { DebugRotationPanel } from '@/components/DebugRotationPanel';
|
||||
import { X, Camera, Trash2, Wrench } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface ItemDetailModalProps {
|
||||
@@ -12,6 +13,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,8 +21,10 @@ export default function ItemDetailModal({
|
||||
onClose,
|
||||
onPhotoUpdated,
|
||||
onItemRefresh,
|
||||
backendUrl = '',
|
||||
}: ItemDetailModalProps) {
|
||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||
const [showDebugPanel, setShowDebugPanel] = useState(false);
|
||||
const [currentPhoto, setCurrentPhoto] = useState<{ thumbnail_url: string; full_url: string } | null>(
|
||||
item.photo_path && item.photo_thumbnail_path
|
||||
? { thumbnail_url: item.photo_thumbnail_path, full_url: item.photo_path }
|
||||
@@ -69,13 +73,25 @@ export default function ItemDetailModal({
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-surface border-b border-slate-800/50 p-4 md:p-6 flex items-center justify-between">
|
||||
<h2 className="text-xl md:text-2xl font-normal text-white truncate">{item.name}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-800 rounded-full text-muted hover:text-white transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentPhoto && (
|
||||
<button
|
||||
onClick={() => setShowDebugPanel(true)}
|
||||
className="p-2 hover:bg-yellow-900/50 rounded-full text-yellow-400 hover:text-yellow-300 transition-colors"
|
||||
title="Debug rotation & crop"
|
||||
aria-label="Debug panel"
|
||||
>
|
||||
<Wrench size={20} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-800 rounded-full text-muted hover:text-white transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -119,7 +135,7 @@ export default function ItemDetailModal({
|
||||
<div className="space-y-3">
|
||||
<div className="relative bg-slate-900/50 rounded-2xl overflow-hidden border border-slate-800/50 aspect-video max-h-96">
|
||||
<img
|
||||
src={currentPhoto.full_url}
|
||||
src={buildPhotoUrl(backendUrl, currentPhoto.full_url)}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
@@ -176,6 +192,31 @@ export default function ItemDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug Rotation Panel */}
|
||||
{showDebugPanel && currentPhoto && (
|
||||
<DebugRotationPanel
|
||||
item={item}
|
||||
imageUrl={buildPhotoUrl(backendUrl, currentPhoto.full_url)}
|
||||
originalPhotoPath={
|
||||
item.labels_data
|
||||
? (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(item.labels_data);
|
||||
const origPath = parsed.image_processing?.original_photo_path;
|
||||
return origPath ? buildPhotoUrl(backendUrl, origPath) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})()
|
||||
: undefined
|
||||
}
|
||||
originalCropBounds={item.labels_data ? JSON.parse(item.labels_data).image_processing?.crop_bounds : undefined}
|
||||
originalRotation={item.labels_data ? JSON.parse(item.labels_data).image_processing?.rotation_degrees : undefined}
|
||||
imageProcessing={item.labels_data ? JSON.parse(item.labels_data).image_processing : undefined}
|
||||
onClose={() => setShowDebugPanel(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ export function useAIExtraction(inventory: Item[], onComplete: (itemData: any) =
|
||||
imageProcessing: data.image_processing
|
||||
})
|
||||
};
|
||||
|
||||
onComplete(newItem);
|
||||
|
||||
if (extractedItems.length > 1) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,7 +14,7 @@ const nextConfig = {
|
||||
// Format: comma-separated list (e.g., "localhost,127.0.0.1,*.local,100.78.182.*")
|
||||
allowedDevOrigins: process.env.ALLOWED_DEV_ORIGINS
|
||||
? process.env.ALLOWED_DEV_ORIGINS.split(',').map(o => o.trim())
|
||||
: ["localhost", "127.0.0.1", "*.local"],
|
||||
: ["localhost", "127.0.0.1", "*.local", "192.168.*", "10.*", "172.16.*"],
|
||||
};
|
||||
|
||||
export default withPWA(nextConfig);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 143 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
@@ -17,11 +17,28 @@ fi
|
||||
|
||||
echo "🚀 Starting TFM aInventory Stack with Dual Proxy..."
|
||||
|
||||
# 1. Kill potentially hanging processes
|
||||
echo "Sweep: Cleaning up old processes..."
|
||||
pkill -f "uvicorn" || true
|
||||
pkill -f "next-server" || true
|
||||
pkill -f "local-ssl-proxy" || true
|
||||
# 1. COMPREHENSIVE process cleanup - ensure absolute clean state
|
||||
echo "Sweep: Comprehensive process cleanup..."
|
||||
|
||||
# Kill all known hanging processes
|
||||
pkill -9 -f "uvicorn" 2>/dev/null || true
|
||||
pkill -9 -f "next-server" 2>/dev/null || true
|
||||
pkill -9 -f "local-ssl-proxy" 2>/dev/null || true
|
||||
pkill -9 -f "python.*backend" 2>/dev/null || true
|
||||
pkill -9 -f "python.*main:app" 2>/dev/null || true
|
||||
pkill -9 -f "npm run dev" 2>/dev/null || true
|
||||
pkill -9 -f "node.*next" 2>/dev/null || true
|
||||
|
||||
# Kill any remaining Python processes on port 8000 (backend)
|
||||
fuser -k 8000/tcp 2>/dev/null || true
|
||||
fuser -k 3001/tcp 2>/dev/null || true
|
||||
fuser -k 3002/tcp 2>/dev/null || true
|
||||
fuser -k 3003/tcp 2>/dev/null || true
|
||||
|
||||
# Extra wait to ensure processes are fully dead
|
||||
sleep 1
|
||||
|
||||
echo "✅ Cleanup complete - all processes terminated"
|
||||
|
||||
# 2. Setup/Activate Virtual Environment
|
||||
if [ ! -f ".venv/bin/activate" ]; then
|
||||
@@ -120,7 +137,23 @@ echo "🔐 Starting SSL proxies on $PROXY_HOSTNAME..."
|
||||
npx local-ssl-proxy --source $BACKEND_SSL_PORT --target $BACKEND_PORT --hostname $PROXY_HOSTNAME > /dev/null 2>&1 &
|
||||
npx local-ssl-proxy --source $FRONTEND_SSL_PORT --target $FRONTEND_PORT --hostname $PROXY_HOSTNAME > /dev/null 2>&1 &
|
||||
|
||||
# 7. Print Unified Access Banner
|
||||
# 7. Signal handlers for clean shutdown
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "🛑 Shutting down services..."
|
||||
pkill -P $$ > /dev/null 2>&1
|
||||
kill $(jobs -p) 2>/dev/null || true
|
||||
pkill -f "uvicorn" 2>/dev/null || true
|
||||
pkill -f "next-server" 2>/dev/null || true
|
||||
pkill -f "local-ssl-proxy" 2>/dev/null || true
|
||||
echo "✅ Cleanup complete"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Trap Ctrl-C (SIGINT) and other termination signals
|
||||
trap cleanup SIGINT SIGTERM EXIT
|
||||
|
||||
# 8. Print Unified Access Banner
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
@@ -150,7 +183,7 @@ echo ""
|
||||
echo -e " ${YELLOW}${BOLD}NOTE:${NC} If you see a 'Not Private' warning,"
|
||||
echo -e " Click 'Advanced' -> 'Proceed' to continue."
|
||||
echo -e "${GREEN}=======================================================${NC}"
|
||||
echo "Keep this window open while working."
|
||||
echo "Keep this window open while working. Press Ctrl-C to stop."
|
||||
echo -e "${GREEN}=======================================================${NC}"
|
||||
|
||||
# Wait for background processes
|
||||
|
||||
Reference in New Issue
Block a user