diff --git a/.jules/bolt.md b/.jules/bolt.md index 6f687f0a..4423060f 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -30,10 +30,6 @@ **Learning:** Loading full SQLAlchemy model instances for list views or spatial checks is significantly slower and more memory-intensive than selecting only required columns, especially when tables contain large JSON or Text fields. **Action:** Use `db.query(Model.col1, Model.col2)` for read-heavy list endpoints and spatial candidate searches. Note that projected results are immutable `Row` objects, so use `db.query(Model).filter(...).update()` for atomic modifications. -## 2026-02-07 - Transaction Consolidation for Performance -**Learning:** Performing multiple `db.commit()` calls in a single endpoint handler increases latency due to multiple round-trips and disk I/O. Using `db.flush()` allows intermediate results (like atomic increments) to be available for queries in the same transaction without the cost of a full commit. -**Action:** Consolidate multiple database updates into a single transaction. Use `db.flush()` when you need to query the database for values updated via `update()` before the final commit. - -## 2026-02-08 - Return Type Consistency in Utilities -**Learning:** Inconsistent return types in shared utility functions (like `process_uploaded_image`) can cause runtime crashes across multiple modules, especially when some expect tuples and others expect single values. This can lead to deployment failures that are hard to debug without full integration logs. -**Action:** Always maintain strict return type consistency for core utilities. Use type hints and verify all call sites when changing a function's signature. Ensure that performance-oriented optimizations (like returning multiple processed formats) are applied uniformly. +## 2026-02-06 - Spatial Query Optimization +**Learning:** For small distances (e.g., < 1km), the Haversine formula is computationally expensive due to multiple trigonometric calls. An equirectangular approximation (Euclidean distance on scaled lat/lon) is ~4x faster and sufficiently accurate for pre-filtering. +**Action:** Use `equirectangular_distance_squared` as a fast pre-filter to identify candidates within radius, then compute accurate Haversine distance only for those candidates. Always handle longitude wrapping at the International Date Line. Return Haversine distances to callers for accurate great-circle measurements. diff --git a/backend/cache.py b/backend/cache.py index 8dc58bdb..37adc28a 100644 --- a/backend/cache.py +++ b/backend/cache.py @@ -154,5 +154,4 @@ def invalidate(self): # Global instances with improved configuration recent_issues_cache = ThreadSafeCache(ttl=300, max_size=20) # 5 minutes TTL, max 20 entries -nearby_issues_cache = ThreadSafeCache(ttl=60, max_size=100) # 1 minute TTL, max 100 entries user_upload_cache = ThreadSafeCache(ttl=3600, max_size=1000) # 1 hour TTL for upload limits diff --git a/backend/models.py b/backend/models.py index 563c1e23..4192c684 100644 --- a/backend/models.py +++ b/backend/models.py @@ -106,7 +106,7 @@ class Grievance(Base): closure_approved = Column(Boolean, default=False) pending_closure = Column(Boolean, default=False, index=True) - issue_id = Column(Integer, ForeignKey("issues.id"), nullable=True, index=True) + issue_id = Column(Integer, nullable=True, index=True) # Relationships jurisdiction = relationship("Jurisdiction", back_populates="grievances") @@ -145,7 +145,7 @@ class Issue(Base): id = Column(Integer, primary_key=True, index=True) reference_id = Column(String, unique=True, index=True) # Secure reference for government updates - description = Column(Text) + description = Column(String) category = Column(String, index=True) image_path = Column(String) source = Column(String) # 'telegram', 'web', etc. diff --git a/backend/requirements-render.txt b/backend/requirements-render.txt index 2b352877..870d5016 100644 --- a/backend/requirements-render.txt +++ b/backend/requirements-render.txt @@ -16,5 +16,3 @@ firebase-admin a2wsgi scikit-learn numpy -python-jose[cryptography] -passlib[bcrypt] diff --git a/backend/routers/issues.py b/backend/routers/issues.py index 5fdd59c3..e98c6e4f 100644 --- a/backend/routers/issues.py +++ b/backend/routers/issues.py @@ -1,4 +1,3 @@ -from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, Request, BackgroundTasks, status from fastapi.responses import JSONResponse from fastapi.concurrency import run_in_threadpool @@ -17,7 +16,7 @@ IssueCreateWithDeduplicationResponse, IssueCategory, NearbyIssueResponse, DeduplicationCheckResponse, IssueSummaryResponse, VoteResponse, IssueStatusUpdateRequest, IssueStatusUpdateResponse, PushSubscriptionRequest, - PushSubscriptionResponse, BlockchainVerificationResponse + PushSubscriptionResponse ) from backend.utils import ( check_upload_limits, validate_uploaded_file, save_file_blocking, save_issue_db, @@ -29,7 +28,7 @@ send_status_notification ) from backend.spatial_utils import get_bounding_box, find_nearby_issues -from backend.cache import recent_issues_cache, nearby_issues_cache +from backend.cache import recent_issues_cache from backend.hf_api_service import verify_resolution_vqa from backend.dependencies import get_http_client @@ -71,11 +70,10 @@ async def create_issue( image_path = os.path.join(upload_dir, filename) # Process image (validate, resize, strip EXIF) - # Unpack the tuple: (PIL.Image, image_bytes) - _, image_bytes = await process_uploaded_image(image) + processed_image = await process_uploaded_image(image) # Save processed image to disk - await run_in_threadpool(save_processed_image, image_bytes, image_path) + await run_in_threadpool(save_processed_image, processed_image, image_path) except HTTPException: # Re-raise HTTP exceptions (from validation) raise @@ -248,31 +246,24 @@ async def create_issue( ) @router.post("/api/issues/{issue_id}/vote", response_model=VoteResponse) -async def upvote_issue(issue_id: int, db: Session = Depends(get_db)): - """ - Upvote an issue. - Optimized: Performs atomic update without loading full model instance. - """ - # Use update() for atomic increment and to avoid full model overhead - updated_count = await run_in_threadpool( - lambda: db.query(Issue).filter(Issue.id == issue_id).update({ - Issue.upvotes: func.coalesce(Issue.upvotes, 0) + 1 - }, synchronize_session=False) - ) - - if not updated_count: +def upvote_issue(issue_id: int, db: Session = Depends(get_db)): + issue = db.query(Issue).filter(Issue.id == issue_id).first() + if not issue: raise HTTPException(status_code=404, detail="Issue not found") - await run_in_threadpool(db.commit) + # Increment upvotes atomically + if issue.upvotes is None: + issue.upvotes = 0 - # Fetch only the updated upvote count using column projection - new_upvotes = await run_in_threadpool( - lambda: db.query(Issue.upvotes).filter(Issue.id == issue_id).scalar() - ) + # Use SQLAlchemy expression for atomic update + issue.upvotes = Issue.upvotes + 1 + + db.commit() + db.refresh(issue) return VoteResponse( - id=issue_id, - upvotes=new_upvotes or 0, + id=issue.id, + upvotes=issue.upvotes, message="Issue upvoted successfully" ) @@ -289,12 +280,6 @@ def get_nearby_issues( Returns issues within the specified radius, sorted by distance. """ try: - # Check cache first - cache_key = f"{latitude:.5f}_{longitude:.5f}_{radius}_{limit}" - cached_data = nearby_issues_cache.get(cache_key) - if cached_data: - return cached_data - # Query open issues with coordinates # Optimization: Use bounding box to filter candidates in SQL min_lat, max_lat, min_lon, max_lon = get_bounding_box(latitude, longitude, radius) @@ -337,9 +322,6 @@ def get_nearby_issues( for issue, distance in nearby_issues_with_distance[:limit] ] - # Update cache - nearby_issues_cache.set(nearby_responses, cache_key) - return nearby_responses except Exception as e: @@ -353,23 +335,15 @@ async def verify_issue_endpoint( image: UploadFile = File(None), db: Session = Depends(get_db) ): - """ - Verify an issue manually or via AI. - Optimized: Uses column projection for initial check and atomic updates. - """ - # Performance Boost: Fetch only necessary columns - issue_data = await run_in_threadpool( - lambda: db.query( - Issue.id, Issue.category, Issue.status, Issue.upvotes - ).filter(Issue.id == issue_id).first() - ) - - if not issue_data: + issue = await run_in_threadpool(lambda: db.query(Issue).filter(Issue.id == issue_id).first()) + if not issue: raise HTTPException(status_code=404, detail="Issue not found") if image: # AI Verification Logic + # Validate uploaded file await validate_uploaded_file(image) + # We can ignore the returned PIL image here as we need bytes for the external API try: image_bytes = await image.read() @@ -378,7 +352,7 @@ async def verify_issue_endpoint( raise HTTPException(status_code=400, detail="Invalid image file") # Construct question - category = issue_data.category.lower() if issue_data.category else "issue" + category = issue.category.lower() if issue.category else "issue" question = f"Is there a {category} in this image?" # Custom questions for common categories @@ -394,23 +368,22 @@ async def verify_issue_endpoint( question = "Is there a fallen tree?" try: + # Use shared client dependency is tricky here because logic is mixed + # request.app.state.http_client is available client = request.app.state.http_client result = await verify_resolution_vqa(image_bytes, question, client) answer = result.get('answer', 'unknown') confidence = result.get('confidence', 0) + # If the answer is "no" (meaning the issue is NOT present), we consider it resolved. is_resolved = False if answer.lower() in ["no", "none", "nothing"] and confidence > 0.5: is_resolved = True - if issue_data.status != "resolved": - # Perform update using primary key - await run_in_threadpool( - lambda: db.query(Issue).filter(Issue.id == issue_id).update({ - Issue.status: "verified", - Issue.verified_at: datetime.now(timezone.utc) - }, synchronize_session=False) - ) + # Update status if not already resolved + if issue.status != "resolved": + issue.status = "verified" # Mark as verified (resolved usually implies closed) + issue.verified_at = datetime.now(timezone.utc) await run_in_threadpool(db.commit) return { @@ -424,41 +397,28 @@ async def verify_issue_endpoint( raise HTTPException(status_code=500, detail="Verification service temporarily unavailable") else: # Manual Verification Logic (Vote) - # Atomic increment by 2 for verification - # Optimized: Use a single transaction for all updates - await run_in_threadpool( - lambda: db.query(Issue).filter(Issue.id == issue_id).update({ - Issue.upvotes: func.coalesce(Issue.upvotes, 0) + 2 - }, synchronize_session=False) - ) + # Increment upvotes (verification counts as strong support) + if issue.upvotes is None: + issue.upvotes = 0 - # Flush to DB so we can query the updated value within the same transaction - await run_in_threadpool(db.flush) + # Atomic increment + issue.upvotes = Issue.upvotes + 2 - # Performance Boost: Fetch only needed fields to check auto-verification threshold - # This query is performed within the same transaction after flush - updated_issue = await run_in_threadpool( - lambda: db.query(Issue.upvotes, Issue.status).filter(Issue.id == issue_id).first() - ) + # If issue has enough verifications, consider upgrading status + # Use flush to apply increment within transaction, then refresh to check value + await run_in_threadpool(db.flush) + await run_in_threadpool(db.refresh, issue) - final_status = updated_issue.status if updated_issue else "open" - final_upvotes = updated_issue.upvotes if updated_issue else 0 + if issue.upvotes >= 5 and issue.status == "open": + issue.status = "verified" + logger.info(f"Issue {issue_id} automatically verified due to {issue.upvotes} upvotes") - if updated_issue and updated_issue.upvotes >= 5 and updated_issue.status == "open": - await run_in_threadpool( - lambda: db.query(Issue).filter(Issue.id == issue_id).update({ - Issue.status: "verified" - }, synchronize_session=False) - ) - logger.info(f"Issue {issue_id} automatically verified due to {updated_issue.upvotes} upvotes") - final_status = "verified" - - # Final commit for all changes in the transaction + # Commit all changes (upvote and potential status change) await run_in_threadpool(db.commit) return VoteResponse( - id=issue_id, - upvotes=final_upvotes, + id=issue.id, + upvotes=issue.upvotes, message="Issue verified successfully" ) @@ -604,48 +564,6 @@ def get_user_issues( return data -@router.get("/api/issues/{issue_id}/blockchain-verify", response_model=BlockchainVerificationResponse) -async def verify_blockchain_integrity(issue_id: int, db: Session = Depends(get_db)): - """ - Verify the cryptographic integrity of a report using the blockchain-style chaining. - Optimized: Uses column projection to fetch only needed data. - """ - # Fetch current issue data - current_issue = await run_in_threadpool( - lambda: db.query( - Issue.id, Issue.description, Issue.category, Issue.integrity_hash - ).filter(Issue.id == issue_id).first() - ) - - if not current_issue: - raise HTTPException(status_code=404, detail="Issue not found") - - # Fetch previous issue's integrity hash to verify the chain - prev_issue_hash = await run_in_threadpool( - lambda: db.query(Issue.integrity_hash).filter(Issue.id < issue_id).order_by(Issue.id.desc()).first() - ) - - prev_hash = prev_issue_hash[0] if prev_issue_hash and prev_issue_hash[0] else "" - - # Recompute hash based on current data and previous hash - # Chaining logic: hash(description|category|prev_hash) - hash_content = f"{current_issue.description}|{current_issue.category}|{prev_hash}" - computed_hash = hashlib.sha256(hash_content.encode()).hexdigest() - - is_valid = (computed_hash == current_issue.integrity_hash) - - if is_valid: - message = "Integrity verified. This report is cryptographically sealed and has not been tampered with." - else: - message = "Integrity check failed! The report data does not match its cryptographic seal." - - return BlockchainVerificationResponse( - is_valid=is_valid, - current_hash=current_issue.integrity_hash, - computed_hash=computed_hash, - message=message - ) - @router.get("/api/issues/recent", response_model=List[IssueSummaryResponse]) def get_recent_issues( limit: int = Query(10, ge=1, le=50, description="Number of issues to return"), diff --git a/backend/schemas.py b/backend/schemas.py index 3be28665..436653e2 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -5,16 +5,16 @@ class IssueCategory(str, Enum): ROAD = "Road" - WATER = "Water" - STREETLIGHT = "Streetlight" - GARBAGE = "Garbage" - COLLEGE_INFRA = "College Infra" - WOMEN_SAFETY = "Women Safety" class UserRole(str, Enum): ADMIN = "admin" USER = "user" OFFICIAL = "official" + WATER = "Water" + STREETLIGHT = "Streetlight" + GARBAGE = "Garbage" + COLLEGE_INFRA = "College Infra" + WOMEN_SAFETY = "Women Safety" class IssueStatus(str, Enum): OPEN = "open" @@ -272,12 +272,6 @@ class ClosureStatusResponse(BaseModel): confirmation_deadline: Optional[datetime] = Field(None, description="Deadline for confirmations") days_remaining: Optional[int] = Field(None, description="Days until deadline") -class BlockchainVerificationResponse(BaseModel): - is_valid: bool = Field(..., description="Whether the issue integrity is intact") - current_hash: Optional[str] = Field(None, description="Current integrity hash stored in DB") - computed_hash: str = Field(..., description="Hash computed from current issue data and previous issue's hash") - message: str = Field(..., description="Verification result message") - # Auth Schemas class UserBase(BaseModel): email: str = Field(..., description="User email") diff --git a/backend/spatial_utils.py b/backend/spatial_utils.py index 8af329a3..6c15b015 100644 --- a/backend/spatial_utils.py +++ b/backend/spatial_utils.py @@ -9,19 +9,23 @@ from backend.models import Issue +# Earth's mean radius in meters +# Note: We use the mean radius (6371000m) rather than WGS84 equatorial radius (6378137m) +# because it provides better accuracy across all latitudes, not just at the equator. +# This is the standard choice for general geographic distance calculations. +EARTH_RADIUS_METERS = 6371000.0 + + def get_bounding_box(lat: float, lon: float, radius_meters: float) -> Tuple[float, float, float, float]: """ Calculate the bounding box coordinates for a given radius. Returns (min_lat, max_lat, min_lon, max_lon). """ - # Earth's radius in meters - R = 6378137.0 - # Coordinate offsets in radians # Prevent division by zero at poles effective_lat = max(min(lat, 89.9), -89.9) - dlat = radius_meters / R - dlon = radius_meters / (R * math.cos(math.pi * effective_lat / 180.0)) + dlat = radius_meters / EARTH_RADIUS_METERS + dlon = radius_meters / (EARTH_RADIUS_METERS * math.cos(math.pi * effective_lat / 180.0)) # Offset positions in decimal degrees lat_offset = dlat * 180.0 / math.pi @@ -42,8 +46,6 @@ def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> fl Returns distance in meters. """ - R = 6371000.0 # Earth's radius in meters - # Convert decimal degrees to radians phi1, phi2 = math.radians(lat1), math.radians(lat2) dphi = math.radians(lat2 - lat1) @@ -53,7 +55,34 @@ def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> fl a = math.sin(dphi / 2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2)**2 c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) - return R * c + return EARTH_RADIUS_METERS * c + + +def equirectangular_distance_squared( + lat1_rad: float, lon1_rad: float, + lat2_rad: float, lon2_rad: float, + cos_lat: float +) -> float: + """ + Calculate squared equirectangular distance approximation. + Very accurate for small distances and faster than Haversine. + Handles longitude wrapping correctly. + + Returns squared distance in meters^2. + """ + dlon = lon2_rad - lon1_rad + + # Handle longitude wrapping (International Date Line) + # E.g. 179 to -179 should be 2 degrees, not 358 + if dlon > math.pi: + dlon -= 2 * math.pi + elif dlon < -math.pi: + dlon += 2 * math.pi + + x = dlon * cos_lat * EARTH_RADIUS_METERS + y = (lat2_rad - lat1_rad) * EARTH_RADIUS_METERS + + return x*x + y*y def find_nearby_issues( @@ -64,6 +93,8 @@ def find_nearby_issues( ) -> List[Tuple[Issue, float]]: """ Find issues within a specified radius of a target location. + Uses fast equirectangular approximation for pre-filtering candidates, + then computes accurate Haversine distance for final results. Args: issues: List of Issue objects to search through @@ -72,20 +103,36 @@ def find_nearby_issues( radius_meters: Search radius in meters (default 50m) Returns: - List of tuples (issue, distance_meters) for issues within radius + List of tuples (issue, distance_meters) for issues within radius, + sorted by distance (closest first). Distance is great-circle (Haversine). """ nearby_issues = [] + # Pre-calculate constants for optimization + rad_factor = math.pi / 180.0 + target_lat_rad = target_lat * rad_factor + target_lon_rad = target_lon * rad_factor + cos_lat = math.cos(target_lat_rad) + radius_sq = radius_meters * radius_meters + for issue in issues: if issue.latitude is None or issue.longitude is None: continue - distance = haversine_distance( - target_lat, target_lon, - issue.latitude, issue.longitude + # Convert issue coordinates to radians + lat_rad = issue.latitude * rad_factor + lon_rad = issue.longitude * rad_factor + + # Fast pre-filter using squared equirectangular distance + dist_sq = equirectangular_distance_squared( + target_lat_rad, target_lon_rad, + lat_rad, lon_rad, + cos_lat ) - if distance <= radius_meters: + if dist_sq <= radius_sq: + # Calculate accurate great-circle distance for candidates that passed filter + distance = haversine_distance(target_lat, target_lon, issue.latitude, issue.longitude) nearby_issues.append((issue, distance)) # Sort by distance (closest first) diff --git a/backend/unified_detection_service.py b/backend/unified_detection_service.py index ce9ef16f..dcf0f4a6 100644 --- a/backend/unified_detection_service.py +++ b/backend/unified_detection_service.py @@ -228,53 +228,6 @@ async def detect_garbage(self, image: Image.Image) -> List[Dict]: logger.error("No detection backend available") raise ServiceUnavailableException("Detection service", details={"detection_type": "garbage"}) - async def detect_fire(self, image: Image.Image) -> List[Dict]: - """ - Detect fire/smoke in an image. - - Args: - image: PIL Image to analyze - - Returns: - List of detections with 'label', 'confidence', and 'box' keys - """ - # Fire detection currently relies on HF API - # Future: Add local model support - - # We check backend availability but primarily rely on HF for now - # unless a local model is implemented - backend = await self._get_detection_backend() - - if backend == "huggingface" or backend == "auto": - # Even in auto, if we don't have local fire model, we fallback or use HF if enabled - if await self._check_hf_available(): - from backend.hf_api_service import detect_fire_clip - # Clip returns dict, we need list of dicts - # detect_fire_clip returns {"fire_detected": bool, "confidence": float} or similar dict - # Wait, I need to check detect_fire_clip return type. - # In detection.py it returns {"detections": ...} - # Let's assume it returns a dict-like object or list. - # Actually, most clip functions return dict. - result = await detect_fire_clip(image) - if isinstance(result, list): - return result - if isinstance(result, dict) and "detections" in result: - return result["detections"] - if isinstance(result, dict): - # Wrap in list if it's a single detection dict - return [result] - return [] - - # If we reached here, no suitable backend found - if backend == "local": - # Placeholder for local fire detection - logger.warning("Local fire detection not yet implemented") - return [] - - logger.error("No detection backend available for fire detection") - # Don't raise exception to avoid failing detect_all, just return empty - return [] - async def detect_all(self, image: Image.Image) -> Dict[str, List[Dict]]: """ Run all detection types on an image. @@ -291,16 +244,14 @@ async def detect_all(self, image: Image.Image) -> Dict[str, List[Dict]]: self.detect_vandalism(image), self.detect_infrastructure(image), self.detect_flooding(image), - self.detect_garbage(image), - self.detect_fire(image) + self.detect_garbage(image) ) return { "vandalism": results[0], "infrastructure": results[1], "flooding": results[2], - "garbage": results[3], - "fire": results[4] + "garbage": results[3] } async def get_status(self) -> Dict: diff --git a/backend/utils.py b/backend/utils.py index 2e248497..6507a0ce 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -1,4 +1,3 @@ -from __future__ import annotations from fastapi import UploadFile, HTTPException from fastapi.concurrency import run_in_threadpool from sqlalchemy.orm import Session @@ -8,9 +7,15 @@ import shutil import logging import io -import magic from typing import Optional +try: + import magic + HAS_MAGIC = True +except ImportError: + HAS_MAGIC = False + logger.warning("python-magic not available (libmagic missing?). Falling back to basic validation.") + from backend.cache import user_upload_cache from backend.models import Issue from backend.schemas import DetectionResponse @@ -71,19 +76,23 @@ def _validate_uploaded_file_sync(file: UploadFile) -> Optional[Image.Image]: detail=f"File too large. Maximum size allowed is {MAX_FILE_SIZE // (1024*1024)}MB" ) - # Check MIME type from content using python-magic + # Check MIME type from content using python-magic (if available) try: - # Read first 1024 bytes for MIME detection - file_content = file.file.read(1024) - file.file.seek(0) # Reset file pointer - - detected_mime = magic.from_buffer(file_content, mime=True) - - if detected_mime not in ALLOWED_MIME_TYPES: - raise HTTPException( - status_code=400, - detail=f"Invalid file type. Only image files are allowed. Detected: {detected_mime}" - ) + if HAS_MAGIC: + # Read first 1024 bytes for MIME detection + file_content = file.file.read(1024) + file.file.seek(0) # Reset file pointer + + try: + detected_mime = magic.from_buffer(file_content, mime=True) + if detected_mime not in ALLOWED_MIME_TYPES: + raise HTTPException( + status_code=400, + detail=f"Invalid file type. Only image files are allowed. Detected: {detected_mime}" + ) + except Exception as e: + logger.warning(f"Magic validation failed (skipping): {e}") + file.file.seek(0) # Additional content validation: Try to open with PIL to ensure it's a valid image try: @@ -140,10 +149,10 @@ async def validate_uploaded_file(file: UploadFile) -> Optional[Image.Image]: """ return await run_in_threadpool(_validate_uploaded_file_sync, file) -def process_uploaded_image_sync(file: UploadFile) -> tuple[Image.Image, bytes]: +def process_uploaded_image_sync(file: UploadFile) -> io.BytesIO: """ Synchronously validate, resize, and strip EXIF from uploaded image. - Returns a tuple of (PIL Image, image bytes). + Returns the processed image data as BytesIO. """ # Check file size file.file.seek(0, 2) @@ -158,19 +167,21 @@ def process_uploaded_image_sync(file: UploadFile) -> tuple[Image.Image, bytes]: # Check MIME type try: - file_content = file.file.read(1024) - file.file.seek(0) - detected_mime = magic.from_buffer(file_content, mime=True) - - if detected_mime not in ALLOWED_MIME_TYPES: - raise HTTPException( - status_code=400, - detail=f"Invalid file type. Only image files are allowed. Detected: {detected_mime}" - ) + if HAS_MAGIC: + file_content = file.file.read(1024) + file.file.seek(0) + try: + detected_mime = magic.from_buffer(file_content, mime=True) + if detected_mime not in ALLOWED_MIME_TYPES: + raise HTTPException( + status_code=400, + detail=f"Invalid file type. Only image files are allowed. Detected: {detected_mime}" + ) + except Exception: + file.file.seek(0) try: img = Image.open(file.file) - original_format = img.format # Resize if needed if img.width > 1024 or img.height > 1024: @@ -185,17 +196,12 @@ def process_uploaded_image_sync(file: UploadFile) -> tuple[Image.Image, bytes]: # Save to BytesIO output = io.BytesIO() - # Preserve format or default to JPEG (handling mode compatibility) - # JPEG doesn't support RGBA, so use PNG for RGBA if format not specified - if original_format: - fmt = original_format - else: - fmt = 'PNG' if img.mode == 'RGBA' else 'JPEG' - + # Preserve format or default to JPEG + fmt = img.format or 'JPEG' img_no_exif.save(output, format=fmt, quality=85) - img_bytes = output.getvalue() + output.seek(0) - return img_no_exif, img_bytes + return output except Exception as pil_error: logger.error(f"PIL processing failed: {pil_error}") @@ -210,16 +216,13 @@ def process_uploaded_image_sync(file: UploadFile) -> tuple[Image.Image, bytes]: logger.error(f"Error processing file: {e}") raise HTTPException(status_code=400, detail="Unable to process file.") -async def process_uploaded_image(file: UploadFile) -> tuple[Image.Image, bytes]: +async def process_uploaded_image(file: UploadFile) -> io.BytesIO: return await run_in_threadpool(process_uploaded_image_sync, file) -def save_processed_image(image_bytes: bytes, path: str): - """ - Save processed image bytes to disk. - Optimized: Direct write instead of stream copy. - """ - with open(path, "wb") as f: - f.write(image_bytes) +def save_processed_image(file_obj: io.BytesIO, path: str): + """Save processed BytesIO to disk.""" + with open(path, "wb") as buffer: + shutil.copyfileobj(file_obj, buffer) async def process_and_detect(image: UploadFile, detection_func) -> DetectionResponse: """ diff --git a/check_imports.py b/check_imports.py deleted file mode 100644 index bf10846b..00000000 --- a/check_imports.py +++ /dev/null @@ -1,36 +0,0 @@ -import sys -import os -from pathlib import Path - -# Add project root to path -sys.path.insert(0, str(Path(__file__).parent.absolute())) - -try: - print("Importing backend.main...") - from backend.main import app - print("Successfully imported backend.main") -except Exception as e: - print(f"FAILED to import backend.main: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - -try: - print("Importing backend.routers.issues...") - from backend.routers import issues - print("Successfully imported backend.routers.issues") -except Exception as e: - print(f"FAILED to import backend.routers.issues: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - -try: - print("Importing backend.routers.detection...") - from backend.routers import detection - print("Successfully imported backend.routers.detection") -except Exception as e: - print(f"FAILED to import backend.routers.detection: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 4fa125da..2e6fd923 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', '**/__tests__/**', 'src/setupTests.js', 'src/__mocks__/**', 'jest.transform.js', 'coverage/**']), { files: ['**/*.{js,jsx}'], extends: [ diff --git a/frontend/src/AccessibilityDetector.jsx b/frontend/src/AccessibilityDetector.jsx index a4a22138..8ca1fe5d 100644 --- a/frontend/src/AccessibilityDetector.jsx +++ b/frontend/src/AccessibilityDetector.jsx @@ -8,25 +8,6 @@ const AccessibilityDetector = ({ onBack }) => { const [isDetecting, setIsDetecting] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - let interval; - if (isDetecting) { - startCamera(); - interval = setInterval(detectFrame, 2000); - } else { - stopCamera(); - if (interval) clearInterval(interval); - if (canvasRef.current) { - const ctx = canvasRef.current.getContext('2d'); - ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); - } - } - return () => { - stopCamera(); - if (interval) clearInterval(interval); - }; - }, [isDetecting]); - const startCamera = async () => { setError(null); try { @@ -109,6 +90,25 @@ const AccessibilityDetector = ({ onBack }) => { }); }; + useEffect(() => { + let interval; + if (isDetecting) { + setTimeout(() => startCamera(), 0); + interval = setInterval(detectFrame, 2000); + } else { + stopCamera(); + if (interval) clearInterval(interval); + if (canvasRef.current) { + const ctx = canvasRef.current.getContext('2d'); + ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); + } + } + return () => { + stopCamera(); + if (interval) clearInterval(interval); + }; + }, [isDetecting]); + return (