diff --git a/README.md b/README.md index f59cf075f..d585aeb87 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ Upgrading from a release before sign-in identities (the `user_identities` table) | Variable | Description | Required | |----------|-------------|----------| | `BG_REMOVAL_PROVIDER` | Background removal backend: `rembg` or `http` (default: `rembg`) | No | -| `BG_REMOVAL_MODEL` | rembg model name (default: `u2net`) | No | +| `BG_REMOVAL_MODEL` | rembg model name (default: `birefnet-general-lite`) | No | | `BG_REMOVAL_URL` | URL for HTTP bg removal provider | If http | | `BG_REMOVAL_API_KEY` | API key for HTTP bg removal provider | No | | `NEXT_PUBLIC_ENABLE_IP_LOCATION_FALLBACK` | Enable IP-based approximate location when browser geolocation is denied/unavailable. Off by default (sends the user's IP to a third party). Set to `true` to enable | No | @@ -400,12 +400,12 @@ If browser geolocation is denied or unavailable, an optional fallback can approx ### Background Removal -Works out of the box: the backend image ships rembg with the default `u2net` model already downloaded. +Works out of the box: the backend image ships rembg with the default `birefnet-general-lite` model already downloaded. It is more accurate on clothes than `u2net`, and the cut-out is cleaned afterwards: small holes the model punched into a print are filled back in, loose bits of background are dropped, and a result that lost the garment entirely keeps the original photo.
Other rembg models, or an HTTP provider -Change the rembg model with `BG_REMOVAL_MODEL` (options: `isnet-general-use`, `silueta`, `u2netp`). A non-default model is downloaded the first time background removal runs, so that first run needs internet access; the compose files keep it in the `rembg_models` volume so later runs work offline. On Kubernetes, mount a PersistentVolume at `/opt/rembg/models` on the image-worker if you change the model, or it is downloaded again on every pod start. The volume must be writable by uid 1000: the manifests' `fsGroup` covers a PersistentVolumeClaim, but a `hostPath` volume needs its directory owned by that uid. The worker links the baked `u2net` into the volume on start, so switching back needs no download. +Change the rembg model with `BG_REMOVAL_MODEL` (options: `u2net`, `isnet-general-use`, `silueta`, `u2netp`). A non-default model is downloaded the first time background removal runs, so that first run needs internet access; the compose files keep it in the `rembg_models` volume so later runs work offline. On Kubernetes, mount a PersistentVolume at `/opt/rembg/models` on the image-worker if you change the model, or it is downloaded again on every pod start. The volume must be writable by uid 1000: the manifests' `fsGroup` covers a PersistentVolumeClaim, but a `hostPath` volume needs its directory owned by that uid. The worker links the baked `birefnet-general-lite` into the volume on start, so switching back needs no download. To use an HTTP provider (e.g. [withoutbg](https://github.com/nicholasgasior/withoutbg)) instead: diff --git a/backend/Dockerfile b/backend/Dockerfile index ed7b5b60e..672d26eba 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -21,7 +21,7 @@ RUN pip install --no-cache-dir -r requirements.txt -r requirements-extras.txt # Importing rembg JIT-compiles pymatting through numba for over a minute, so the bake also fills # the numba cache. A generic CPU target keeps those entries valid on hosts other than the builder, # and the cache stays writable by every uid because numba refuses a cache dir it cannot write to. -ARG BG_REMOVAL_MODEL=u2net +ARG BG_REMOVAL_MODEL=birefnet-general-lite ENV REMBG_HOME=/opt/rembg \ NUMBA_CACHE_DIR=/opt/numba_cache \ NUMBA_CPU_NAME=generic diff --git a/backend/app/api/analytics.py b/backend/app/api/analytics.py index 7a9894d21..b9e075ea7 100644 --- a/backend/app/api/analytics.py +++ b/backend/app/api/analytics.py @@ -12,6 +12,7 @@ from app.models.user import User from app.utils.auth import get_current_user from app.utils.clothing import WardrobeComposition, count_composition +from app.utils.garment_vocabulary import CATEGORIES, TYPE_CATEGORY from app.utils.signed_urls import sign_optional router = APIRouter(prefix="/analytics", tags=["Analytics"]) @@ -29,6 +30,28 @@ class TypeDistribution(BaseModel): percentage: float +class CategoryDistribution(BaseModel): + category: str + count: int + percentage: float + + +def category_distribution(types: list[TypeDistribution]) -> list[CategoryDistribution]: + """Clothing, footwear and accessories totals; every section is listed, even when empty.""" + counts = dict.fromkeys(CATEGORIES, 0) + for entry in types: + counts[TYPE_CATEGORY.get(entry.type, "clothing")] += entry.count + total = sum(counts.values()) + return [ + CategoryDistribution( + category=category, + count=count, + percentage=round(count / total * 100, 1) if total else 0, + ) + for category, count in counts.items() + ] + + class WearStats(BaseModel): id: UUID name: str | None @@ -108,6 +131,7 @@ class AnalyticsResponse(BaseModel): wardrobe: WardrobeStats color_distribution: list[ColorDistribution] type_distribution: list[TypeDistribution] + category_distribution: list[CategoryDistribution] = [] most_worn: list[WearStats] least_worn: list[WearStats] never_worn: list[WearStats] @@ -431,6 +455,7 @@ def wear_stats_query(order_desc: bool, limit: int, never_worn: bool = False): wardrobe=wardrobe_stats, color_distribution=color_distribution, type_distribution=type_distribution, + category_distribution=category_distribution(type_distribution), most_worn=most_worn, least_worn=least_worn, never_worn=never_worn, diff --git a/backend/app/api/items.py b/backend/app/api/items.py index 3bb2fc6c6..51017513d 100644 --- a/backend/app/api/items.py +++ b/backend/app/api/items.py @@ -7,7 +7,7 @@ from arq import create_pool from arq.jobs import Job from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status -from sqlalchemy import case, func, select, update +from sqlalchemy import case, delete, func, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -19,6 +19,7 @@ ClothingItem, ItemHistory, ItemImage, + ItemPhotoView, ItemStatus, ProcessingKind, TaggedBy, @@ -27,6 +28,7 @@ from app.models.outfit import Outfit, OutfitItem from app.models.user import User from app.schemas.item import ( + UNIQUE_PHOTO_VIEWS, USER_TAG_KEYS, AnalysisCompletion, AnalysisFailure, @@ -45,6 +47,7 @@ BulkSelectionRequest, BulkUploadResponse, BulkUploadResult, + CategoryName, ItemCreate, ItemFilter, ItemImageResponse, @@ -53,11 +56,14 @@ ItemUpdate, LogWashRequest, LogWearRequest, + PhotoViewName, + PhotoViewRequest, RemoveBackgroundRequest, ReorderImagesRequest, TaggingProgressResponse, WashHistoryResponse, ) +from app.services import background_removal from app.services.image_service import ImageService from app.services.item_service import ItemService from app.utils.auth import get_current_user @@ -112,6 +118,9 @@ async def _resolve_bulk_item_ids( item_ids = await item_service.get_ids_by_filter( user_id=user_id, type_filter=filters.type if filters else None, + category=filters.category if filters else None, + favorite=filters.favorite if filters else None, + needs_wash=filters.needs_wash if filters else None, search=filters.search if filters else None, is_archived=filters.is_archived if filters and filters.is_archived is not None else False, excluded_ids=list(request.excluded_ids) if request.excluded_ids else None, @@ -142,6 +151,7 @@ async def list_items( current_user: Annotated[User, Depends(get_current_user)], pagination: Annotated[PaginationParams, Depends()], type: str | None = None, + category: CategoryName | None = None, subtype: str | None = None, colors: str | None = None, status: str | None = None, @@ -157,6 +167,7 @@ async def list_items( filters = ItemFilter( type=type, + category=category, subtype=subtype, colors=color_list, status=status, @@ -186,6 +197,130 @@ async def list_items( ) +UPLOAD_BACKGROUND = "#FFFFFF" + + +async def _queue_upload_processing( + redis, item: ClothingItem, full_image_path: str, remove_background: bool, tag: bool = True +): + """Tagging for a new upload, preceded by background removal when the upload asked for it: + the image worker cleans the photo first and then queues the tagging itself. Without + tagging (AI off or skipped) the removal still runs, and the worker marks the item ready.""" + if remove_background: + item.processing_kind = ProcessingKind.background_removal + return await redis.enqueue_job( + "remove_item_background_job", + str(item.id), + UPLOAD_BACKGROUND, + full_image_path if tag else None, + _queue_name=IMAGE_QUEUE, + ) + return await redis.enqueue_job( + "tag_item_image", + str(item.id), + full_image_path, + _queue_name=TAGGING_QUEUE, + ) + + +def _delete_item_files(image_service: ImageService, item: ClothingItem) -> None: + """Remove every file an item owns from storage: the cover with its sizes and backup, and + each additional photo.""" + image_service.delete_images( + { + "image_path": item.image_path, + "medium_path": item.medium_path, + "thumbnail_path": item.thumbnail_path, + "original_backup_path": item.original_image_path, + } + ) + for image in item.additional_images: + image_service.delete_images( + { + "image_path": image.image_path, + "medium_path": image.medium_path, + "thumbnail_path": image.thumbnail_path, + "original_backup_path": image.original_image_path, + } + ) + + +# A photo still "processing" after this long lost its job (worker restart, dropped queue entry) +# and may be queued again. +PHOTO_JOB_STALE_SECONDS = 900 + + +def _photo_busy(image: ItemImage) -> bool: + if image.status != "processing" or image.processing_started_at is None: + return False + return ( + datetime.now(UTC) - image.processing_started_at + ).total_seconds() < PHOTO_JOB_STALE_SECONDS + + +async def _queue_photo_backgrounds( + db: AsyncSession, images: list[ItemImage], bg_color: str, redis=None +) -> int: + """Queue one background-removal job per photo. Returns how many were queued; a photo whose + job could not be queued is marked error so the user can retry it on its own.""" + if not images: + return 0 + started = datetime.now(UTC) + for image in images: + image.status = "processing" + image.processing_started_at = started + # Committed before enqueuing: the image worker reads the row on its own connection. + await db.commit() + + own_pool = redis is None + queued = 0 + try: + if own_pool: + redis = await create_pool(get_redis_settings()) + for image in images: + try: + job = await redis.enqueue_job( + "remove_photo_background_job", str(image.id), bg_color, _queue_name=IMAGE_QUEUE + ) + if job is None: + raise RuntimeError("enqueue_job returned None") + queued += 1 + except Exception as e: + logger.error(f"Failed to queue background removal for photo {image.id}: {e}") + image.status = "error" + image.processing_started_at = None + except Exception as e: + logger.error(f"Failed to connect to the job queue for photo background removal: {e}") + for image in images: + image.status = "error" + image.processing_started_at = None + finally: + if own_pool and redis is not None: + await redis.aclose() + await db.commit() + return queued + + +async def _photo_views(db: AsyncSession, item_id: UUID) -> dict[str, str]: + rows = await db.execute(select(ItemPhotoView).where(ItemPhotoView.item_id == item_id)) + return {row.image_path: row.view for row in rows.scalars()} + + +async def _label_photo( + db: AsyncSession, item: ClothingItem, image_path: str, view: str | None +) -> None: + """Set the view of one photo. Front and back belong to one photo at a time, so labelling a + second front takes the label off the first.""" + clash = ItemPhotoView.image_path == image_path + if view in UNIQUE_PHOTO_VIEWS: + clash = clash | (ItemPhotoView.view == view) + await db.execute(delete(ItemPhotoView).where(ItemPhotoView.item_id == item.id, clash)) + if view: + db.add(ItemPhotoView(item_id=item.id, image_path=image_path, view=view)) + await db.flush() + await db.refresh(item, attribute_names=["photo_views"]) + + @router.post("", response_model=ItemResponse, status_code=status.HTTP_201_CREATED) async def create_item( db: DbSession, @@ -200,6 +335,8 @@ async def create_item( primary_color: str | None = Form(None), favorite: bool = Form(False), skip_ai: bool = Form(False), + remove_background: bool = Form(False), + image_view: PhotoViewName | None = Form(None), ) -> ItemResponse: # Validate and process image image_service = ImageService() @@ -262,10 +399,13 @@ async def create_item( item_data=item_data, image_paths=image_paths, ) + if image_view: + await _label_photo(db, item, item.image_path, image_view) do_auto_tag = settings.effective_ai_vision_enabled and not skip_ai - if do_auto_tag: + # Background removal is not AI tagging: it runs when asked for even with tagging off. + if do_auto_tag or remove_background: # Commit before enqueuing: the worker runs in another process on its own # connection, so a job handed over while this transaction is still open # dequeues against a row it cannot see. @@ -274,20 +414,20 @@ async def create_item( redis = await create_pool(get_redis_settings()) try: full_image_path = str(image_service.get_image_path(image_paths["image_path"])) - job = await redis.enqueue_job( - "tag_item_image", - str(item.id), - full_image_path, - _queue_name=TAGGING_QUEUE, + job = await _queue_upload_processing( + redis, item, full_image_path, remove_background, tag=do_auto_tag ) item.ai_job_id = job.job_id await db.commit() await db.refresh(item, attribute_names=["updated_at"]) - logger.info(f"Queued AI tagging job for item {item.id}") + logger.info(f"Queued upload processing for item {item.id}") finally: await redis.aclose() except Exception as e: - logger.error(f"Failed to queue AI tagging job: {e}") + logger.error(f"Failed to queue upload processing: {e}") + if not do_auto_tag: + # Nothing else would ever move it out of processing. + item = await item_service.mark_pending(item, set_ready=True) else: item = await item_service.mark_pending(item, set_ready=True) @@ -300,6 +440,9 @@ async def bulk_create_items( current_user: Annotated[User, Depends(get_current_user)], images: list[UploadFile] = File(..., description="Multiple image files to upload"), skip_ai: bool = Form(False), + remove_background: bool = Form( + False, description="Remove each photo's background before the AI tags it" + ), upload_keys: list[str] | None = Form( None, description="Optional per-file idempotency keys, same order/length as images. " @@ -338,9 +481,11 @@ async def bulk_create_items( user_id = current_user.id do_auto_tag = settings.effective_ai_vision_enabled and not skip_ai + # Background removal is not AI tagging: it runs when asked for even with tagging off. + queue_jobs = do_auto_tag or remove_background redis = None - if do_auto_tag: + if queue_jobs: try: redis = await create_pool(get_redis_settings()) except Exception as e: @@ -433,7 +578,7 @@ async def bulk_create_items( upload_key=upload_key, ) - if not do_auto_tag: + if not queue_jobs or (redis is None and not do_auto_tag): item = await item_service.mark_pending(item, set_ready=True) elif redis: # Commit per item before handing the job over. Batching the @@ -445,18 +590,17 @@ async def bulk_create_items( full_image_path = str( image_service.get_image_path(image_paths["image_path"]) ) - job = await redis.enqueue_job( - "tag_item_image", - str(item.id), - full_image_path, - _queue_name=TAGGING_QUEUE, + job = await _queue_upload_processing( + redis, item, full_image_path, remove_background, tag=do_auto_tag ) item.ai_job_id = job.job_id await db.commit() await db.refresh(item, attribute_names=["updated_at"]) - logger.info(f"Queued AI tagging for bulk item {item.id}") + logger.info(f"Queued upload processing for bulk item {item.id}") except Exception as e: - logger.error(f"Failed to queue AI tagging for {item.id}: {e}") + logger.error(f"Failed to queue upload processing for {item.id}: {e}") + if not do_auto_tag: + item = await item_service.mark_pending(item, set_ready=True) results.append( BulkUploadResult( @@ -552,14 +696,7 @@ async def bulk_delete_items( failed += 1 continue - image_service.delete_images( - { - "image_path": item.image_path, - "medium_path": item.medium_path, - "thumbnail_path": item.thumbnail_path, - "original_backup_path": item.original_image_path, - } - ) + _delete_item_files(image_service, item) await item_service.delete(item) deleted += 1 @@ -1022,11 +1159,22 @@ async def bulk_remove_background_items( if redis: await redis.aclose() + # The other photos of the same items, one job each: a removal that stopped at the cover + # would leave the back and the details with their background. + photos = [ + image + for item in items_to_process + for image in item.additional_images + if not image.original_image_path and not _photo_busy(image) + ] + photos_queued = await _queue_photo_backgrounds(db, photos, request.bg_color) + return BulkRemoveBackgroundResponse( queued=queued, failed=failed, skipped=skipped, already_done=already_done_count, + photos_queued=photos_queued, errors=errors, next_cursor=next_cursor, has_more=has_more, @@ -1313,15 +1461,7 @@ async def delete_item( detail="Item not found", ) - image_service = ImageService() - image_service.delete_images( - { - "image_path": item.image_path, - "medium_path": item.medium_path, - "thumbnail_path": item.thumbnail_path, - "original_backup_path": item.original_image_path, - } - ) + _delete_item_files(ImageService(), item) await item_service.delete(item) @@ -1786,52 +1926,67 @@ async def remove_item_background( detail="Item has a background job in progress", ) - recovering_from_error = ( - item.status == ItemStatus.error - and item.processing_kind == ProcessingKind.background_removal - ) - - hex_color = request.bg_color.lstrip("#") - bg_color = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) - try: - image_service = ImageService() - result = await asyncio.to_thread(image_service.remove_background, item.image_path, bg_color) - item.original_image_path = result["original_backup_path"] - if recovering_from_error: - item.status = ItemStatus.ready - item.processing_kind = None - item.ai_started_at = None - await db.commit() - await db.refresh( - item, - attribute_names=[ - "original_image_path", - "status", - "processing_kind", - "ai_started_at", - "updated_at", - ], - ) - return ItemResponse.model_validate(item) - except ImportError: + background_removal.get_provider() + except (ImportError, ValueError): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Background removal provider not available. " "For rembg: pip install rembg[cpu]. " "For HTTP provider: set BG_REMOVAL_PROVIDER=http and BG_REMOVAL_URL.", ) from None - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from None + + # The model runs in the image worker only. Running it here as well kept a second copy of it + # in the API process's memory for good; the worker frees its own after a while idle. + item.status = ItemStatus.processing + item.processing_kind = ProcessingKind.background_removal + item.ai_started_at = None + await db.commit() + try: + redis = await create_pool(get_redis_settings()) + try: + job = await redis.enqueue_job( + "remove_item_background_job", + str(item.id), + request.bg_color, + _queue_name=IMAGE_QUEUE, + ) + finally: + await redis.aclose() + if job is None: + raise RuntimeError("enqueue_job returned None") + item.ai_job_id = job.job_id except Exception as e: - logger.error(f"Failed to remove background: {e}") + logger.error(f"Failed to queue background removal for {item.id}: {e}") + # Kind stays background_removal so the card offers the right retry. + item.status = ItemStatus.error + await db.commit() raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to remove background", + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Could not queue background removal", ) from None + await db.commit() + # Same as the bulk action: the item's other photos get their own job, otherwise the back + # or a detail shot keeps its background after the user asked for the item to be cleaned. + photos = [ + image + for image in item.additional_images + if not image.original_image_path and not _photo_busy(image) + ] + await _queue_photo_backgrounds(db, photos, request.bg_color) + await db.refresh( + item, + attribute_names=[ + "original_image_path", + "status", + "processing_kind", + "ai_started_at", + "ai_job_id", + "image_path", + "updated_at", + ], + ) + return ItemResponse.model_validate(item) @router.post("/{item_id}/restore-original", response_model=ItemResponse) @@ -1944,6 +2099,14 @@ async def replace_item_image( # Old files are removed only after the new paths are committed, so a failed # commit cannot leave the item pointing at deleted files image_service.delete_images(old_paths) + # The replaced photo's view label goes with it; the new photo starts unlabelled. + await db.execute( + delete(ItemPhotoView).where( + ItemPhotoView.item_id == item.id, ItemPhotoView.image_path == old_paths["image_path"] + ) + ) + await db.commit() + await db.refresh(item, attribute_names=["photo_views"]) return ItemResponse.model_validate(item) @@ -1956,6 +2119,8 @@ async def add_item_image( db: DbSession, current_user: Annotated[User, Depends(get_current_user)], image: UploadFile = File(...), + view: PhotoViewName | None = Form(None), + remove_background: bool = Form(False), ) -> ItemImageResponse: item_service = ItemService(db) item = await item_service.get_by_id(item_id, current_user.id) @@ -1966,13 +2131,14 @@ async def add_item_image( detail="Item not found", ) - # Check max images limit + # The cover counts towards the limit too + max_photos = get_settings().max_item_photos count_result = await db.execute(select(func.count()).where(ItemImage.item_id == item_id)) current_count = count_result.scalar() or 0 - if current_count >= 4: + if current_count + 1 >= max_photos: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Maximum of 4 additional images per item", + detail=f"Maximum of {max_photos} photos per item", ) # Process image @@ -2008,6 +2174,11 @@ async def add_item_image( db.add(item_image) await db.flush() await db.refresh(item_image) + if view: + await _label_photo(db, item, item_image.image_path, view) + if remove_background: + await _queue_photo_backgrounds(db, [item_image], UPLOAD_BACKGROUND) + item_image.view = view return ItemImageResponse.model_validate(item_image) @@ -2046,9 +2217,18 @@ async def delete_item_image( "image_path": item_image.image_path, "medium_path": item_image.medium_path, "thumbnail_path": item_image.thumbnail_path, + "original_backup_path": item_image.original_image_path, } ) + await db.execute( + delete(ItemPhotoView).where( + ItemPhotoView.item_id == item_id, ItemPhotoView.image_path == item_image.image_path + ) + ) + # Out of the loaded item too, so anything served from this session no longer lists it. + if item_image in item.additional_images: + item.additional_images.remove(item_image) await db.delete(item_image) await db.flush() @@ -2079,10 +2259,106 @@ async def reorder_item_images( await db.flush() # Return in new order + views = await _photo_views(db, item_id) ordered = sorted(images.values(), key=lambda x: x.position) + for img in ordered: + img.view = views.get(img.image_path) return [ItemImageResponse.model_validate(img) for img in ordered] +async def _owned_photo( + db: AsyncSession, item_id: UUID, image_id: UUID, user_id: UUID +) -> tuple[ClothingItem, ItemImage]: + item = await ItemService(db).get_by_id(item_id, user_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + image = next((img for img in item.additional_images if img.id == image_id), None) + if image is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found") + return item, image + + +@router.post("/{item_id}/images/{image_id}/remove-background", response_model=ItemImageResponse) +async def remove_photo_background( + item_id: UUID, + image_id: UUID, + db: DbSession, + current_user: Annotated[User, Depends(get_current_user)], + request: RemoveBackgroundRequest | None = None, +) -> ItemImageResponse: + """Queue background removal for one additional photo; the cover has its own endpoint.""" + _, image = await _owned_photo(db, item_id, image_id, current_user.id) + if _photo_busy(image): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="This photo is already being processed" + ) + try: + background_removal.get_provider() + except (ImportError, ValueError): + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="Background removal provider not available", + ) from None + bg_color = request.bg_color if request else UPLOAD_BACKGROUND + await _queue_photo_backgrounds(db, [image], bg_color) + image.view = (await _photo_views(db, item_id)).get(image.image_path) + return ItemImageResponse.model_validate(image) + + +@router.post("/{item_id}/images/{image_id}/restore-original", response_model=ItemImageResponse) +async def restore_photo_original( + item_id: UUID, + image_id: UUID, + db: DbSession, + current_user: Annotated[User, Depends(get_current_user)], +) -> ItemImageResponse: + """Undo background removal on one additional photo.""" + _, image = await _owned_photo(db, item_id, image_id, current_user.id) + if _photo_busy(image): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="This photo is still being processed" + ) + if not image.original_image_path: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="This photo has no original to restore" + ) + try: + await asyncio.to_thread( + ImageService().restore_original, image.image_path, image.original_image_path + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from None + image.original_image_path = None + image.status = "ready" + await db.commit() + image.view = (await _photo_views(db, item_id)).get(image.image_path) + return ItemImageResponse.model_validate(image) + + +@router.patch("/{item_id}/photo-view", response_model=ItemResponse) +async def set_photo_view( + item_id: UUID, + request: PhotoViewRequest, + db: DbSession, + current_user: Annotated[User, Depends(get_current_user)], +) -> ItemResponse: + """Label one of the item's photos as its front, back, a detail or other; null clears it.""" + item = await ItemService(db).get_by_id(item_id, current_user.id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + + if request.image_id is None: + image_path = item.image_path + else: + image = next((img for img in item.additional_images if img.id == request.image_id), None) + if image is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found") + image_path = image.image_path + + await _label_photo(db, item, image_path, request.view) + return ItemResponse.model_validate(item) + + @router.post("/{item_id}/images/{image_id}/set-primary", response_model=ItemResponse) async def set_primary_image( item_id: UUID, @@ -2110,20 +2386,31 @@ async def set_primary_image( detail="Image not found", ) - # Swap paths: current primary -> additional, additional -> primary + # A job writing either photo would land on the wrong one after the swap. + if _photo_busy(item_image) or (item.status == ItemStatus.processing and item.ai_job_id): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A photo of this item is still being processed", + ) + + # Swap paths: current primary -> additional, additional -> primary. Each photo keeps its own + # background-removal backup, so undo restores the right picture. old_primary = { "image_path": item.image_path, "thumbnail_path": item.thumbnail_path, "medium_path": item.medium_path, + "original_image_path": item.original_image_path, } item.image_path = item_image.image_path item.thumbnail_path = item_image.thumbnail_path item.medium_path = item_image.medium_path + item.original_image_path = item_image.original_image_path item_image.image_path = old_primary["image_path"] item_image.thumbnail_path = old_primary["thumbnail_path"] item_image.medium_path = old_primary["medium_path"] + item_image.original_image_path = old_primary["original_image_path"] await db.flush() await db.refresh(item) diff --git a/backend/app/config.py b/backend/app/config.py index 6fd87af5b..5195b4b41 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -165,9 +165,15 @@ def normalize_reasoning_effort(cls, v: str | None) -> str | None: # walked in batches via the response cursor rather than rejected. max_bulk_action_count: int = Field(default=200, ge=1) + # Photos one item can have, the cover included (front, back, details...). + max_item_photos: int = Field(default=10, ge=1, le=30) + # Background removal bg_removal_provider: str = Field(default="rembg") # "rembg" or "http" - bg_removal_model: str = Field(default="u2net") # rembg model name + bg_removal_model: str = Field(default="birefnet-general-lite") # rembg model name + # Seconds without background-removal work before the image worker frees the model's + # memory; the next photo loads it again (a few seconds). 0 keeps it loaded. + bg_removal_idle_seconds: int = Field(default=600, ge=0) bg_removal_url: str | None = Field(default=None) # URL for http provider (e.g. withoutbg) bg_removal_api_key: str | None = Field(default=None) # API key for http provider diff --git a/backend/app/data/garment_vocabulary.json b/backend/app/data/garment_vocabulary.json index bef2e1a56..78899b455 100644 --- a/backend/app/data/garment_vocabulary.json +++ b/backend/app/data/garment_vocabulary.json @@ -32,8 +32,18 @@ {"value": "scarf", "role": "accessory", "wash_interval": 10}, {"value": "belt", "role": "accessory", "wash_interval": 20}, {"value": "bag", "role": "accessory", "wash_interval": 20}, - {"value": "accessories", "role": "accessory", "wash_interval": 20} + {"value": "accessories", "role": "accessory", "wash_interval": 20}, + {"value": "ring", "role": "accessory", "wash_interval": 100}, + {"value": "necklace", "role": "accessory", "wash_interval": 100}, + {"value": "watch", "role": "accessory", "wash_interval": 100}, + {"value": "bracelet", "role": "accessory", "wash_interval": 100}, + {"value": "earrings", "role": "accessory", "wash_interval": 100} ], + "categories": { + "clothing": ["full_body", "base_top", "mid_layer", "suit", "outer_layer", "bottom", "legwear", "socks"], + "footwear": ["footwear"], + "accessories": ["neckwear", "accessory"] + }, "materials": ["cotton", "denim", "leather", "wool", "polyester", "silk", "linen", "knit", "fleece", "suede", "velvet", "nylon", "canvas", "down", "shearling"], "formality": ["very-casual", "casual", "smart-casual", "business-casual", "formal", "very-formal"], "occasions": [ diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c5107fb06..ae31f26a5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,12 @@ from app.models.family import Family, FamilyInvite from app.models.identity import UserIdentity -from app.models.item import ClothingItem, ItemHistory, ItemImage, WashHistory +from app.models.item import ( + ClothingItem, + ItemHistory, + ItemImage, + ItemPhotoView, + WashHistory, +) from app.models.learning import ( ItemPairScore, OutfitPerformance, @@ -28,6 +34,7 @@ "ClothingItem", "ItemHistory", "ItemImage", + "ItemPhotoView", "WashHistory", "FamilyOutfitRating", "Outfit", diff --git a/backend/app/models/item.py b/backend/app/models/item.py index aeb298ad3..bcfc23f46 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -6,6 +6,7 @@ from sqlalchemy import ( Boolean, + CheckConstraint, Date, DateTime, Enum, @@ -167,6 +168,10 @@ class ClothingItem(Base): cascade="all, delete-orphan", order_by="ItemImage.position", ) + # Loaded with the item wherever it is queried, so every ItemResponse can label its photos. + photo_views: Mapped[list["ItemPhotoView"]] = relationship( + "ItemPhotoView", cascade="all, delete-orphan", lazy="selectin", passive_deletes=True + ) class ItemHistory(Base): @@ -211,6 +216,9 @@ class WashHistory(Base): class ItemImage(Base): __tablename__ = "item_images" + __table_args__ = ( + CheckConstraint("status IN ('ready', 'processing', 'error')", name="ck_item_images_status"), + ) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) item_id: Mapped[uuid.UUID] = mapped_column( @@ -221,8 +229,44 @@ class ItemImage(Base): thumbnail_path: Mapped[str | None] = mapped_column(String(500)) medium_path: Mapped[str | None] = mapped_column(String(500)) position: Mapped[int] = mapped_column(Integer, default=0) + # Each photo has its own background removal: its own state and its own untouched original, + # so one failing photo never touches the others or the cover. + status: Mapped[str] = mapped_column(String(12), default="ready", server_default="ready") + original_image_path: Mapped[str | None] = mapped_column(String(500)) + processing_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) # Relationships item: Mapped["ClothingItem"] = relationship("ClothingItem", back_populates="additional_images") + + +class PhotoView(enum.StrEnum): + front = "front" + back = "back" + detail = "detail" + other = "other" + + +class ItemPhotoView(Base): + """What one of an item's photos shows (front, back, detail...). + + Keyed by the photo's file rather than by item_images row, so the label follows the photo when + it becomes or stops being the cover (set-primary swaps paths), and a replaced photo simply + loses its label. A table of its own rather than a clothing_items column, like declutter_keeps, + so it never touches how items are written. Photos without a row have no view. + """ + + __tablename__ = "item_photo_views" + __table_args__ = ( + CheckConstraint( + "view IN ('front', 'back', 'detail', 'other')", name="ck_item_photo_views_view" + ), + ) + + item_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("clothing_items.id", ondelete="CASCADE"), primary_key=True + ) + image_path: Mapped[str] = mapped_column(String(500), primary_key=True) + view: Mapped[str] = mapped_column(String(10), nullable=False) + diff --git a/backend/app/schemas/item.py b/backend/app/schemas/item.py index 6a9f8fc16..ecdce757d 100644 --- a/backend/app/schemas/item.py +++ b/backend/app/schemas/item.py @@ -4,11 +4,12 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator +from sqlalchemy import inspect as sa_inspect from app.models.item import ProcessingKind, TaggingStatus from app.schemas.color import ColorList, ColorName from app.schemas.outfit import Occasion -from app.utils.garment_vocabulary import DEFAULT_WASH_INTERVALS +from app.utils.garment_vocabulary import CATEGORIES, DEFAULT_WASH_INTERVALS, TYPE_CATEGORY from app.utils.signed_urls import sign_image_url, sign_optional @@ -30,6 +31,11 @@ class ItemTags(BaseModel): USER_TAG_KEYS = frozenset({"size", "care_instructions", "source_url"}) +PhotoViewName = Literal["front", "back", "detail", "other"] +# Only one photo of an item can be its front, and one its back; details and others can repeat. +UNIQUE_PHOTO_VIEWS = frozenset({"front", "back"}) +CategoryName = Literal[CATEGORIES] # type: ignore[valid-type] + class ItemBase(BaseModel): type: str = Field(default="unknown", max_length=50) # Default to unknown, AI will detect @@ -90,12 +96,30 @@ def _surface_failure_reason(cls, data: Any) -> Any: setattr(data, dest, raw[src]) return data + @model_validator(mode="before") + @classmethod + def _attach_photo_views(cls, data: Any) -> Any: + # The views live in item_photo_views keyed by file, so each photo picks up its own label + # here. An item loaded without them (or a plain dict) just reports no views. + if isinstance(data, dict): + return data + state = sa_inspect(data, raiseerr=False) + if state is None or "photo_views" in state.unloaded: + return data + views = {row.image_path: row.view for row in data.photo_views} + data.image_view = views.get(data.image_path) + if "additional_images" not in state.unloaded: + for image in data.additional_images: + image.view = views.get(image.image_path) + return data + id: UUID user_id: UUID image_path: str thumbnail_path: str | None = None medium_path: str | None = None original_image_path: str | None = None + image_view: PhotoViewName | None = None tags: dict = Field(default_factory=dict) colors: list[str] = Field(default_factory=list) primary_color: str | None = None @@ -146,6 +170,12 @@ def thumbnail_url(self) -> str | None: def medium_url(self) -> str | None: return sign_optional(self.medium_path) + @computed_field + @property + def category(self) -> str: + # Unknown or legacy types read as clothing, the section every item used to be in. + return TYPE_CATEGORY.get(self.type, "clothing") + @computed_field @property def effective_wash_interval(self) -> int: @@ -209,6 +239,7 @@ class ItemListResponse(BaseModel): class ItemFilter(BaseModel): type: str | None = None + category: CategoryName | None = None subtype: str | None = None colors: ColorList | None = None status: str | None = None @@ -254,7 +285,13 @@ class BulkUploadResponse(BaseModel): class BulkFilters(BaseModel): + # Mirrors the list filters a "select all" was made under, so the action touches exactly the + # items the user saw: without category, favorite and needs_wash it would reach the whole + # wardrobe. type: str | None = None + category: CategoryName | None = None + favorite: bool | None = None + needs_wash: bool | None = None search: str | None = None is_archived: bool | None = None @@ -349,6 +386,8 @@ class BulkRemoveBackgroundResponse(BulkBatchResponse): failed: int skipped: int = 0 already_done: int = 0 + # The items' other photos, each queued as its own job next to the cover. + photos_queued: int = 0 errors: list[str] = Field(default_factory=list) @@ -361,6 +400,10 @@ class ItemImageResponse(BaseModel): thumbnail_path: str | None = None medium_path: str | None = None position: int + view: PhotoViewName | None = None + # Background removal runs per photo, so each one reports its own state. + status: Literal["ready", "processing", "error"] = "ready" + original_image_path: str | None = None created_at: datetime @computed_field @@ -383,6 +426,12 @@ class ReorderImagesRequest(BaseModel): image_ids: list[UUID] +class PhotoViewRequest(BaseModel): + # image_id None means the item's cover photo; view None clears the label. + image_id: UUID | None = None + view: PhotoViewName | None = None + + class RemoveBackgroundRequest(BaseModel): bg_color: str = Field( default="#FFFFFF", diff --git a/backend/app/services/background_removal.py b/backend/app/services/background_removal.py index 6a5c7accb..925ce044e 100644 --- a/backend/app/services/background_removal.py +++ b/backend/app/services/background_removal.py @@ -1,4 +1,5 @@ import logging +import threading from abc import ABC, abstractmethod from io import BytesIO @@ -19,9 +20,16 @@ def remove(self, image: Image.Image) -> Image.Image: # rembg is imported lazily because it is an optional extra (requirements-extras.txt): a # module-level import would break every install that uses the http provider or none. class RembgProvider(BackgroundRemovalProvider): - def __init__(self, model: str = "u2net"): + """Background removal in the image worker, the only model Wardrowbe runs itself: a small + segmentation network on the CPU (no GPU, no LLM). The session is dropped after idle_seconds + without work, so the worker gives its memory back between uploads; 0 keeps it loaded.""" + + def __init__(self, model: str = "birefnet-general-lite", idle_seconds: int = 0): self.model = model + self.idle_seconds = idle_seconds self._session = None + self._lock = threading.Lock() + self._timer: threading.Timer | None = None def _get_session(self): if self._session is None: @@ -30,10 +38,31 @@ def _get_session(self): self._session = new_session(self.model) return self._session + def _unload(self) -> None: + with self._lock: + if self._session is not None: + logger.info(f"Unloading background-removal model {self.model} after idle time") + self._session = None + self._timer = None + + def _schedule_unload(self) -> None: + if self.idle_seconds <= 0: + return + if self._timer is not None: + self._timer.cancel() + self._timer = threading.Timer(self.idle_seconds, self._unload) + self._timer.daemon = True + self._timer.start() + def remove(self, image: Image.Image) -> Image.Image: from rembg import remove - return remove(image, session=self._get_session()) + with self._lock: + session = self._get_session() + try: + return remove(image, session=session) + finally: + self._schedule_unload() class HttpProvider(BackgroundRemovalProvider): @@ -73,7 +102,9 @@ def get_provider() -> BackgroundRemovalProvider: provider_type = settings.bg_removal_provider if provider_type == "rembg": - _provider = RembgProvider(model=settings.bg_removal_model) + _provider = RembgProvider( + model=settings.bg_removal_model, idle_seconds=settings.bg_removal_idle_seconds + ) elif provider_type == "http": if not settings.bg_removal_url: raise ValueError("BG_REMOVAL_URL is required when BG_REMOVAL_PROVIDER=http") diff --git a/backend/app/services/image_service.py b/backend/app/services/image_service.py index ea65db728..b286418ab 100644 --- a/backend/app/services/image_service.py +++ b/backend/app/services/image_service.py @@ -1,3 +1,4 @@ +import os import shutil import uuid from dataclasses import dataclass @@ -11,6 +12,7 @@ from app.config import get_settings from app.services import background_removal +from app.services.mask_cleanup import clean_cutout settings = get_settings() @@ -59,6 +61,17 @@ def _flatten_to_rgb(image: Image.Image) -> Image.Image: return image +def _replace_atomically(target: Path, write) -> None: + """Write through a temporary file next to target, then swap it in: an interrupted or + failed write never leaves a truncated backup that a later restore would trust.""" + tmp = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp") + try: + write(tmp) + os.replace(tmp, target) + finally: + tmp.unlink(missing_ok=True) + + class ImageTooLargeError(ValueError): def __init__(self, pixels: int, limit_pixels: int): self.pixels = pixels @@ -260,14 +273,18 @@ def remove_background( backup_path = f"{base_path}_orig.jpg" backup_full = self.storage_path / backup_path + # A second removal starts again from the true original, so redoing a bad + # cut-out does not cut a cut-out. + source_full = backup_full if backup_full.exists() else original_full + + image = Image.open(source_full).convert("RGB") + provider = background_removal.get_provider() + result = clean_cutout(image, provider.remove(image)) + # First removal wins: a second removal must not overwrite the true # original with an already-processed image if not backup_full.exists(): - shutil.copy2(original_full, backup_full) - - image = Image.open(original_full).convert("RGB") - provider = background_removal.get_provider() - result = provider.remove(image) + _replace_atomically(backup_full, lambda tmp: shutil.copy2(original_full, tmp)) # Composite onto solid color background background = Image.new("RGBA", result.size, (*bg_color, 255)) @@ -310,4 +327,15 @@ def rotate_image(self, image_path: str, direction: str = "cw") -> dict[str, str] rotated = image.rotate(angle, expand=True) - return self._save_all_sizes(rotated, image_path) + # The background-removal backup turns with the photo, so undoing or redoing + # the removal later keeps the rotation. It is written only once the photo and its + # sizes are saved: a failure there leaves both in the old orientation. + backup_full = self.storage_path / f"{image_path.rsplit('.', 1)[0]}_orig.jpg" + backup = None + if backup_full.exists(): + backup = _flatten_to_rgb(Image.open(backup_full)).rotate(angle, expand=True) + + paths = self._save_all_sizes(rotated, image_path) + if backup is not None: + _replace_atomically(backup_full, lambda tmp: backup.save(tmp, "JPEG", quality=95)) + return paths diff --git a/backend/app/services/item_service.py b/backend/app/services/item_service.py index 2df4eeee5..ed1a4d2cc 100644 --- a/backend/app/services/item_service.py +++ b/backend/app/services/item_service.py @@ -15,6 +15,7 @@ WashHistory, ) from app.schemas.item import DEFAULT_WASH_INTERVALS, ItemCreate, ItemFilter, ItemUpdate +from app.utils.garment_vocabulary import TYPES_BY_CATEGORY class ItemService: @@ -60,6 +61,8 @@ async def get_list( # Apply filters if filters.type: query = query.where(ClothingItem.type == filters.type) + if filters.category: + query = query.where(ClothingItem.type.in_(TYPES_BY_CATEGORY[filters.category])) if filters.subtype: query = query.where(ClothingItem.subtype == filters.subtype) if filters.status: @@ -122,6 +125,9 @@ async def get_ids_by_filter( search: str | None = None, is_archived: bool = False, excluded_ids: list[UUID] | None = None, + category: str | None = None, + favorite: bool | None = None, + needs_wash: bool | None = None, after_id: UUID | None = None, limit: int | None = None, ) -> list[UUID]: @@ -129,6 +135,12 @@ async def get_ids_by_filter( if type_filter: query = query.where(ClothingItem.type == type_filter) + if category: + query = query.where(ClothingItem.type.in_(TYPES_BY_CATEGORY[category])) + if favorite is not None: + query = query.where(ClothingItem.favorite == favorite) + if needs_wash is not None: + query = query.where(ClothingItem.needs_wash == needs_wash) query = query.where(ClothingItem.is_archived == is_archived) diff --git a/backend/app/services/mask_cleanup.py b/backend/app/services/mask_cleanup.py new file mode 100644 index 000000000..87138ed16 --- /dev/null +++ b/backend/app/services/mask_cleanup.py @@ -0,0 +1,66 @@ +"""Tidies the cut-out a background-removal model returns before it is composited. + +Models trip over clothes in two typical ways: a white print or a light patch on a +shirt is taken for background and punched out, and bits of the bed or hanger the +garment lies on survive as loose specks. Both are fixed on the mask alone, so this +works the same for every provider. +""" + +import numpy as np +from PIL import Image +from scipy import ndimage + +# Less opaque area than this and the model lost the garment altogether. Low enough for a +# ring or an earring photographed from a distance (0.5% of a 12 MP photo is 60,000 pixels). +MIN_COVERAGE = 0.005 +# Pieces smaller than this share of the biggest one are background specks. Generous +# enough that the second shoe of a pair, a sock, a strap or a detached hood is kept. +KEEP_RATIO = 0.02 +# Holes up to this share of the garment are fabric the model missed; bigger ones are +# real openings, such as the space inside a bag handle. +MAX_HOLE_RATIO = 0.10 +OPAQUE = 128 +EIGHT_WAY = np.ones((3, 3), dtype=bool) + + +class NoGarmentFound(ValueError): + """The model kept (almost) nothing, so its result would wipe the photo.""" + + +def clean_cutout(original: Image.Image, cutout: Image.Image) -> Image.Image: + """Return the cutout with specks dropped and small holes filled back in. + + A filled hole takes its colours from the original photo: providers blank the colour + of the pixels they make transparent, and the hole must show the real fabric. + """ + rgb = original.convert("RGB") + cut = cutout.convert("RGBA") + if cut.size != rgb.size: + cut = cut.resize(rgb.size, Image.Resampling.BILINEAR) + + pixels = np.array(cut, dtype=np.uint8) + alpha = pixels[..., 3] + solid = alpha >= OPAQUE + if solid.mean() < MIN_COVERAGE: + raise NoGarmentFound("No garment found in the photo; the original was kept") + + labels, count = ndimage.label(solid, structure=EIGHT_WAY) + keep = solid + if count > 1: + sizes = ndimage.sum_labels(solid, labels, index=np.arange(1, count + 1)) + kept_ids = np.flatnonzero(sizes >= sizes.max() * KEEP_RATIO) + 1 + keep = np.isin(labels, kept_ids) + # The soft edge around a dropped speck goes with it. + dropped = ndimage.binary_dilation(solid & ~keep, structure=EIGHT_WAY, iterations=2) + alpha[dropped & ~keep] = 0 + + holes = ndimage.binary_fill_holes(keep) & ~keep + if holes.any(): + hole_labels, hole_count = ndimage.label(holes) + hole_sizes = ndimage.sum_labels(holes, hole_labels, index=np.arange(1, hole_count + 1)) + small_ids = np.flatnonzero(hole_sizes <= keep.sum() * MAX_HOLE_RATIO) + 1 + filled = np.isin(hole_labels, small_ids) + pixels[filled, :3] = np.asarray(rgb, dtype=np.uint8)[filled] + alpha[filled] = 255 + + return Image.fromarray(pixels) diff --git a/backend/app/utils/garment_vocabulary.py b/backend/app/utils/garment_vocabulary.py index 219691647..72326bda4 100644 --- a/backend/app/utils/garment_vocabulary.py +++ b/backend/app/utils/garment_vocabulary.py @@ -25,6 +25,16 @@ } COLORS: tuple[str, ...] = tuple(entry["value"] for entry in _DATA["colors"]) COLOR_ALIASES: dict[str, str] = dict(_DATA["color_aliases"]) +# Wardrobe sections shown to the user (clothing, footwear, accessories), each a group of roles. +CATEGORIES: tuple[str, ...] = tuple(_DATA["categories"]) +ROLE_CATEGORY: dict[str, str] = { + role: category for category, roles in _DATA["categories"].items() for role in roles +} +TYPE_CATEGORY: dict[str, str] = {value: ROLE_CATEGORY[role] for value, role in ITEM_ROLE.items()} +TYPES_BY_CATEGORY: dict[str, tuple[str, ...]] = { + category: tuple(value for value in TYPES if TYPE_CATEGORY[value] == category) + for category in CATEGORIES +} # Zero-width space, word joiner and BOM are dropped so that a name made only of them reads as blank diff --git a/backend/app/workers/background_removal.py b/backend/app/workers/background_removal.py index 4563e6e1e..4b7cbd5c4 100644 --- a/backend/app/workers/background_removal.py +++ b/backend/app/workers/background_removal.py @@ -5,19 +5,26 @@ from sqlalchemy import select -from app.models.item import ClothingItem, ItemStatus +from app.models.item import ClothingItem, ItemImage, ItemStatus from app.services.image_service import ImageService from app.workers.db import get_db_session +from app.workers.queues import TAGGING_QUEUE logger = logging.getLogger(__name__) -async def remove_item_background_job(ctx: dict, item_id: str, bg_color_hex: str) -> dict: +async def remove_item_background_job( + ctx: dict, item_id: str, bg_color_hex: str, then_tag_path: str | None = None +) -> dict: """Remove the background for one item and composite it onto bg_color_hex. Deterministic local image op (no external AI call), so no retry/cooldown machinery is needed here - unlike tag_item_image. A failure is just marked as an error status; the user re-selects and retries via the bulk toolbar. + + then_tag_path is set for an upload that asked for both: the AI then tags the + cleaned-up photo once this job is done. A failed removal still hands the item to + tagging with its original photo, so an upload is never left untagged for it. """ hex_color = bg_color_hex.lstrip("#") bg_color = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) @@ -33,19 +40,29 @@ async def remove_item_background_job(ctx: dict, item_id: str, bg_color_hex: str) item.ai_started_at = datetime.now(UTC) await db.commit() + error: str | None = None try: image_service = ImageService() out = await asyncio.to_thread( image_service.remove_background, item.image_path, bg_color ) item.original_image_path = out["original_backup_path"] - item.status = ItemStatus.ready + except Exception as e: + logger.error(f"Background removal failed for item {item_id}: {e}") + error = str(e) + + if then_tag_path is not None: item.ai_started_at = None item.processing_kind = None + item.status = ItemStatus.processing await db.commit() - return {"status": "success", "item_id": item_id} - except Exception as e: - logger.error(f"Background removal failed for item {item_id}: {e}") + await _queue_tagging(ctx, item, then_tag_path) + await db.commit() + if error: + return {"status": "error", "error": error, "item_id": item_id, "tagging": True} + return {"status": "success", "item_id": item_id, "tagging": True} + + if error: # Kind stays "background_removal" so the grid labels and retries # this as a background-removal failure, not a generic AI failure - # and ai_raw_response/ai_failed_at are left untouched since this @@ -53,6 +70,66 @@ async def remove_item_background_job(ctx: dict, item_id: str, bg_color_hex: str) item.status = ItemStatus.error item.ai_started_at = None await db.commit() - return {"status": "error", "error": str(e)} + return {"status": "error", "error": error} + + item.status = ItemStatus.ready + item.ai_started_at = None + item.processing_kind = None + await db.commit() + return {"status": "success", "item_id": item_id} + finally: + await db.close() + + +async def _queue_tagging(ctx: dict, item: ClothingItem, image_path: str) -> None: + redis = ctx.get("redis") + try: + job = await redis.enqueue_job( + "tag_item_image", str(item.id), image_path, _queue_name=TAGGING_QUEUE + ) + if job is None: + raise RuntimeError("enqueue_job returned None") + item.ai_job_id = job.job_id + except Exception as e: + # Left processing without a job, which the stale-processing sweep recovers. + logger.error(f"Failed to queue tagging after background removal for {item.id}: {e}") + item.ai_job_id = None + + +async def remove_photo_background_job(ctx: dict, image_id: str, bg_color_hex: str) -> dict: + """Remove the background of one of an item's additional photos. + + The counterpart of remove_item_background_job for item_images rows: every photo gets its own + job, its own status and its own backup, so a failure here leaves the cover and the other + photos untouched. + """ + hex_color = bg_color_hex.lstrip("#") + bg_color = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + + db = get_db_session(ctx) + try: + image = await db.get(ItemImage, UUID(image_id)) + if image is None: + logger.warning(f"Photo {image_id} not found for background removal job") + return {"status": "error", "error": "Photo not found"} + + try: + out = await asyncio.to_thread( + ImageService().remove_background, image.image_path, bg_color + ) + image.original_image_path = out["original_backup_path"] + image.medium_path = out.get("medium_path", image.medium_path) + image.thumbnail_path = out.get("thumbnail_path", image.thumbnail_path) + image.status = "ready" + error = None + except Exception as e: + logger.error(f"Background removal failed for photo {image_id}: {e}") + image.status = "error" + error = str(e) + image.processing_started_at = None + await db.commit() + if error: + return {"status": "error", "error": error, "image_id": image_id} + return {"status": "success", "image_id": image_id} finally: await db.close() diff --git a/backend/app/workers/image_worker.py b/backend/app/workers/image_worker.py index 372595b04..07a371cb9 100644 --- a/backend/app/workers/image_worker.py +++ b/backend/app/workers/image_worker.py @@ -2,7 +2,10 @@ from app.config import get_settings from app.logging_config import configure_logging -from app.workers.background_removal import remove_item_background_job +from app.workers.background_removal import ( + remove_item_background_job, + remove_photo_background_job, +) from app.workers.db import close_db, init_db from app.workers.queues import IMAGE_QUEUE from app.workers.rotation import rotate_item_image_job @@ -34,6 +37,7 @@ class ImageWorkerSettings: functions = [ rotate_item_image_job, remove_item_background_job, + remove_photo_background_job, ] on_startup = startup diff --git a/backend/migrations/versions/a4c6e8f0b2d3_item_image_processing.py b/backend/migrations/versions/a4c6e8f0b2d3_item_image_processing.py new file mode 100644 index 000000000..49fa9206c --- /dev/null +++ b/backend/migrations/versions/a4c6e8f0b2d3_item_image_processing.py @@ -0,0 +1,40 @@ +"""item images: own background-removal state and original backup per photo + +Revision ID: a4c6e8f0b2d3 +Revises: f3b5c7d9e1a2 +Create Date: 2026-10-18 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "a4c6e8f0b2d3" +down_revision: str | None = "f3b5c7d9e1a2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Additive: every existing photo reads as ready with no backup, which is what it is. + op.add_column( + "item_images", + sa.Column("status", sa.String(12), nullable=False, server_default="ready"), + ) + op.add_column("item_images", sa.Column("original_image_path", sa.String(500), nullable=True)) + op.add_column( + "item_images", + sa.Column("processing_started_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_check_constraint( + "ck_item_images_status", "item_images", "status IN ('ready', 'processing', 'error')" + ) + + +def downgrade() -> None: + op.drop_constraint("ck_item_images_status", "item_images", type_="check") + op.drop_column("item_images", "processing_started_at") + op.drop_column("item_images", "original_image_path") + op.drop_column("item_images", "status") diff --git a/backend/migrations/versions/f3b5c7d9e1a2_item_photo_views.py b/backend/migrations/versions/f3b5c7d9e1a2_item_photo_views.py new file mode 100644 index 000000000..c8fb8e4cb --- /dev/null +++ b/backend/migrations/versions/f3b5c7d9e1a2_item_photo_views.py @@ -0,0 +1,40 @@ +"""item photo views: which photo of an item shows the front, the back or a detail + +Revision ID: f3b5c7d9e1a2 +Revises: a7d3c9e1f5b2 +Create Date: 2026-10-18 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "f3b5c7d9e1a2" +down_revision: str | None = "a7d3c9e1f5b2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Additive only: existing photos simply have no view until the user labels them. + op.create_table( + "item_photo_views", + sa.Column( + "item_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("clothing_items.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column("image_path", sa.String(500), primary_key=True), + sa.Column("view", sa.String(10), nullable=False), + sa.CheckConstraint( + "view IN ('front', 'back', 'detail', 'other')", name="ck_item_photo_views_view" + ), + ) + + +def downgrade() -> None: + op.drop_table("item_photo_views") diff --git a/backend/tests/test_background_unload.py b/backend/tests/test_background_unload.py new file mode 100644 index 000000000..73097f154 --- /dev/null +++ b/backend/tests/test_background_unload.py @@ -0,0 +1,19 @@ +"""The image worker frees the background-removal model after a while without photos.""" + +import time +from unittest.mock import MagicMock, patch + +from PIL import Image + +from app.services.background_removal import RembgProvider + + +class TestBackgroundModelUnload: + def test_the_session_is_dropped_after_idle_time(self): + provider = RembgProvider(model="u2net", idle_seconds=0.05) + fake = MagicMock(new_session=MagicMock(return_value="session"), remove=MagicMock()) + with patch.dict("sys.modules", {"rembg": fake}): + provider.remove(Image.new("RGB", (4, 4))) + assert provider._session == "session" + time.sleep(0.3) + assert provider._session is None diff --git a/backend/tests/test_bulk_rotate_and_remove_background.py b/backend/tests/test_bulk_rotate_and_remove_background.py index eb97b31c8..ae29cf6be 100644 --- a/backend/tests/test_bulk_rotate_and_remove_background.py +++ b/backend/tests/test_bulk_rotate_and_remove_background.py @@ -311,6 +311,7 @@ async def test_queues_selected_items_and_clears_stale_ai_started_at( "failed": 0, "skipped": 0, "already_done": 0, + "photos_queued": 0, "errors": [], "next_cursor": None, "has_more": False, diff --git a/backend/tests/test_deploy_env.py b/backend/tests/test_deploy_env.py index 0438121af..047bcfda3 100644 --- a/backend/tests/test_deploy_env.py +++ b/backend/tests/test_deploy_env.py @@ -13,6 +13,10 @@ # Read by docker-entrypoint.sh to remap appuser before the app starts, never by Settings. ENTRYPOINT_KEYS = {"PUID", "PGID"} +# Read by native libraries inside the process rather than by Settings: OMP_NUM_THREADS caps the +# CPU threads onnxruntime gives background removal in the image worker. +RUNTIME_KEYS = {"OMP_NUM_THREADS"} + # wardrobe-config is shared with the postgres and frontend Deployments, which read these. K8S_NON_BACKEND_KEYS = { "POSTGRES_USER", @@ -62,7 +66,7 @@ def test_notification_settings_reach_the_worker(compose): def test_every_compose_key_is_a_setting(compose): for name in BACKEND_SERVICES: keys = set(compose["services"][name]["environment"]) - unknown = keys - SETTINGS_KEYS - ENTRYPOINT_KEYS + unknown = keys - SETTINGS_KEYS - ENTRYPOINT_KEYS - RUNTIME_KEYS assert not unknown, f"{name} passes keys no Settings field reads: {sorted(unknown)}" diff --git a/backend/tests/test_image_undo_replace.py b/backend/tests/test_image_undo_replace.py index 3b6d290d6..f5c26c792 100644 --- a/backend/tests/test_image_undo_replace.py +++ b/backend/tests/test_image_undo_replace.py @@ -23,6 +23,27 @@ def _jpeg_bytes(color=GREEN, size=(600, 600)) -> bytes: return buf.getvalue() +def _inline_queue(db_session): + """Stands in for redis + the image worker: the queued removal runs right away.""" + from unittest.mock import AsyncMock + + from app.workers.background_removal import remove_item_background_job + + async def enqueue(name, item_id, color, *args, **kwargs): + with ( + patch("app.workers.background_removal.get_db_session", return_value=db_session), + patch.object(db_session, "close", AsyncMock()), + ): + await remove_item_background_job({}, item_id, color) + job = MagicMock() + job.job_id = "inline-job" + return job + + pool = AsyncMock() + pool.enqueue_job.side_effect = enqueue + return patch("app.api.items.create_pool", AsyncMock(return_value=pool)) + + def _mock_provider(): provider = MagicMock() provider.remove.side_effect = lambda img: Image.new("RGBA", img.size, (*BLUE, 255)) @@ -117,9 +138,16 @@ async def test_restore_missing_backup_raises(self, item_with_image: ClothingItem class TestRemoveBackgroundEndpointBackup: @pytest.mark.asyncio async def test_sets_original_image_path( - self, client: AsyncClient, auth_headers, item_with_image: ClothingItem + self, + client: AsyncClient, + auth_headers, + item_with_image: ClothingItem, + db_session: AsyncSession, ): - with patch("app.services.background_removal.get_provider", return_value=_mock_provider()): + with ( + patch("app.services.background_removal.get_provider", return_value=_mock_provider()), + _inline_queue(db_session), + ): response = await client.post( f"/api/v1/items/{item_with_image.id}/remove-background", json={}, @@ -136,9 +164,16 @@ async def test_sets_original_image_path( class TestRestoreOriginalEndpoint: @pytest.mark.asyncio async def test_restores_and_clears_backup( - self, client: AsyncClient, auth_headers, item_with_image: ClothingItem + self, + client: AsyncClient, + auth_headers, + item_with_image: ClothingItem, + db_session: AsyncSession, ): - with patch("app.services.background_removal.get_provider", return_value=_mock_provider()): + with ( + patch("app.services.background_removal.get_provider", return_value=_mock_provider()), + _inline_queue(db_session), + ): removal = await client.post( f"/api/v1/items/{item_with_image.id}/remove-background", json={}, @@ -160,7 +195,11 @@ async def test_restores_and_clears_backup( @pytest.mark.asyncio async def test_no_backup_returns_400( - self, client: AsyncClient, auth_headers, item_with_image: ClothingItem + self, + client: AsyncClient, + auth_headers, + item_with_image: ClothingItem, + db_session: AsyncSession, ): response = await client.post( f"/api/v1/items/{item_with_image.id}/restore-original", @@ -201,7 +240,11 @@ async def test_other_users_item_404( class TestReplaceImageEndpoint: @pytest.mark.asyncio async def test_replaces_image_and_cleans_up( - self, client: AsyncClient, auth_headers, item_with_image: ClothingItem + self, + client: AsyncClient, + auth_headers, + item_with_image: ClothingItem, + db_session: AsyncSession, ): svc = ImageService() old_paths = { @@ -210,7 +253,10 @@ async def test_replaces_image_and_cleans_up( "thumbnail_path": item_with_image.thumbnail_path, } old_hash = item_with_image.image_hash - with patch("app.services.background_removal.get_provider", return_value=_mock_provider()): + with ( + patch("app.services.background_removal.get_provider", return_value=_mock_provider()), + _inline_queue(db_session), + ): removal = await client.post( f"/api/v1/items/{item_with_image.id}/remove-background", json={}, @@ -236,7 +282,11 @@ async def test_replaces_image_and_cleans_up( @pytest.mark.asyncio async def test_invalid_file_400( - self, client: AsyncClient, auth_headers, item_with_image: ClothingItem + self, + client: AsyncClient, + auth_headers, + item_with_image: ClothingItem, + db_session: AsyncSession, ): response = await client.put( f"/api/v1/items/{item_with_image.id}/image", @@ -266,9 +316,16 @@ async def test_item_not_found(self, client: AsyncClient, auth_headers): class TestDeleteCleansBackup: @pytest.mark.asyncio async def test_delete_item_removes_backup( - self, client: AsyncClient, auth_headers, item_with_image: ClothingItem + self, + client: AsyncClient, + auth_headers, + item_with_image: ClothingItem, + db_session: AsyncSession, ): - with patch("app.services.background_removal.get_provider", return_value=_mock_provider()): + with ( + patch("app.services.background_removal.get_provider", return_value=_mock_provider()), + _inline_queue(db_session), + ): removal = await client.post( f"/api/v1/items/{item_with_image.id}/remove-background", json={}, diff --git a/backend/tests/test_image_worker.py b/backend/tests/test_image_worker.py index 4473186a9..0939f8c89 100644 --- a/backend/tests/test_image_worker.py +++ b/backend/tests/test_image_worker.py @@ -10,7 +10,10 @@ from app.models.item import ClothingItem, ItemStatus from app.models.user import User from app.services.image_service import ImageService -from app.workers.background_removal import remove_item_background_job +from app.workers.background_removal import ( + remove_item_background_job, + remove_photo_background_job, +) from app.workers.image_worker import ImageWorkerSettings from app.workers.queues import IMAGE_QUEUE, TAGGING_QUEUE, queue_for_kind from app.workers.rotation import rotate_item_image_job @@ -79,6 +82,7 @@ def test_image_worker_carries_both_image_jobs(self): assert set(ImageWorkerSettings.functions) == { rotate_item_image_job, remove_item_background_job, + remove_photo_background_job, } def test_tagging_worker_no_longer_runs_image_jobs(self): diff --git a/backend/tests/test_mask_cleanup.py b/backend/tests/test_mask_cleanup.py new file mode 100644 index 000000000..cb531cd52 --- /dev/null +++ b/backend/tests/test_mask_cleanup.py @@ -0,0 +1,205 @@ +from io import BytesIO +from unittest.mock import MagicMock, patch + +import pytest +import pytest_asyncio +from PIL import Image, ImageDraw +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.item import ClothingItem, ItemStatus +from app.models.user import User +from app.services.image_service import ImageService +from app.services.mask_cleanup import NoGarmentFound, clean_cutout + +SHIRT = (200, 40, 40) + + +def _photo(size=(200, 200)) -> Image.Image: + width, height = size + photo = Image.new("RGB", size, (240, 240, 240)) + box = (width // 5, height // 5, width * 4 // 5, height * 4 // 5) + ImageDraw.Draw(photo).rectangle(box, fill=SHIRT) + return photo + + +def _cutout(size=(200, 200), shapes=((40, 40, 160, 160),), holes=()) -> Image.Image: + """A provider result: transparent black outside the garment, as rembg returns it.""" + cut = Image.new("RGBA", size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(cut) + for box in shapes: + draw.rectangle(box, fill=(*SHIRT, 255)) + for box in holes: + draw.rectangle(box, fill=(0, 0, 0, 0)) + return cut + + +class TestCleanCutout: + def test_fills_a_print_the_model_punched_out(self): + result = clean_cutout(_photo(), _cutout(holes=[(90, 90, 110, 110)])) + + assert result.getpixel((100, 100)) == (*SHIRT, 255) + + def test_keeps_a_large_opening(self): + # A bag handle: the gap inside is a real opening, not fabric. + cut = _cutout(shapes=[(20, 20, 180, 180)], holes=[(50, 50, 150, 150)]) + result = clean_cutout(_photo(), cut) + + assert result.getpixel((100, 100))[3] == 0 + + def test_drops_loose_bits_of_background(self): + cut = _cutout(shapes=[(40, 40, 160, 160), (2, 2, 6, 6)]) + result = clean_cutout(_photo(), cut) + + assert result.getpixel((4, 4))[3] == 0 + assert result.getpixel((100, 100))[3] == 255 + + def test_keeps_both_shoes_of_a_pair(self): + cut = _cutout(shapes=[(10, 60, 90, 140), (110, 70, 180, 140)]) + result = clean_cutout(_photo(), cut) + + assert result.getpixel((50, 100))[3] == 255 + assert result.getpixel((150, 100))[3] == 255 + + def test_keeps_a_small_detached_part(self): + # A strap or a hood cut off by the model: small next to the body, still fabric. + cut = _cutout(shapes=[(40, 40, 160, 160), (170, 40, 179, 90)]) + result = clean_cutout(_photo(), cut) + + assert result.getpixel((174, 65))[3] == 255 + + def test_accepts_a_small_garment(self): + # A ring in the middle of the photo covers about 1% of it. + result = clean_cutout(_photo(), _cutout(shapes=[(90, 90, 110, 110)])) + + assert result.getpixel((100, 100))[3] == 255 + + def test_an_empty_result_keeps_the_original(self): + with pytest.raises(NoGarmentFound): + clean_cutout(_photo(), _cutout(shapes=[(0, 0, 3, 3)])) + + def test_a_result_of_another_size_is_scaled_to_the_photo(self): + result = clean_cutout(_photo(), _cutout(size=(100, 100), shapes=[(20, 20, 80, 80)])) + + assert result.size == (200, 200) + assert result.getpixel((100, 100))[3] == 255 + assert result.getpixel((5, 5))[3] == 0 + + +def _jpeg_bytes() -> bytes: + buf = BytesIO() + _photo((800, 500)).save(buf, format="JPEG", quality=95) + return buf.getvalue() + + +@pytest_asyncio.fixture +async def item(db_session: AsyncSession, test_user: User) -> ClothingItem: + paths = await ImageService().process_and_store( + user_id=test_user.id, image_data=_jpeg_bytes(), original_filename="shirt.jpg" + ) + item = ClothingItem( + user_id=test_user.id, + type="shirt", + image_path=paths["image_path"], + medium_path=paths["medium_path"], + thumbnail_path=paths["thumbnail_path"], + image_hash=paths["image_hash"], + status=ItemStatus.ready, + ) + db_session.add(item) + await db_session.commit() + await db_session.refresh(item) + return item + + +def _recording_provider(seen: list): + provider = MagicMock() + + def remove(img): + seen.append(img.copy()) + return Image.new("RGBA", img.size, (0, 0, 255, 255)) + + provider.remove.side_effect = remove + return provider + + +class TestRedoFromOriginal: + @pytest.mark.asyncio + async def test_second_removal_starts_from_the_original_photo(self, item: ClothingItem): + svc = ImageService() + seen: list[Image.Image] = [] + with patch( + "app.services.background_removal.get_provider", return_value=_recording_provider(seen) + ): + svc.remove_background(item.image_path) + svc.remove_background(item.image_path) + + # The second run sees the red shirt again, not the first run's blue result. + width, height = seen[1].size + r, g, b = seen[1].getpixel((width // 2, height // 2)) + assert r > 150 and b < 100 + + @pytest.mark.asyncio + async def test_failed_removal_leaves_no_backup(self, item: ClothingItem): + svc = ImageService() + provider = MagicMock() + provider.remove.side_effect = lambda img: Image.new("RGBA", img.size, (0, 0, 0, 0)) + with ( + patch("app.services.background_removal.get_provider", return_value=provider), + pytest.raises(NoGarmentFound), + ): + svc.remove_background(item.image_path) + + backup = item.image_path.rsplit(".", 1)[0] + "_orig.jpg" + assert not svc.get_image_path(backup).exists() + + @pytest.mark.asyncio + async def test_an_interrupted_backup_write_leaves_no_backup(self, item: ClothingItem): + svc = ImageService() + with ( + patch( + "app.services.background_removal.get_provider", + return_value=_recording_provider([]), + ), + patch("app.services.image_service.shutil.copy2", side_effect=OSError("disk full")), + pytest.raises(OSError), + ): + svc.remove_background(item.image_path) + + backup = svc.get_image_path(item.image_path.rsplit(".", 1)[0] + "_orig.jpg") + assert not backup.exists() + assert not list(backup.parent.glob("*.tmp")) + + @pytest.mark.asyncio + async def test_a_failed_rotation_leaves_the_backup_as_it_was(self, item: ClothingItem): + svc = ImageService() + with patch( + "app.services.background_removal.get_provider", return_value=_recording_provider([]) + ): + svc.remove_background(item.image_path) + backup = svc.get_image_path(item.image_path.rsplit(".", 1)[0] + "_orig.jpg") + before = Image.open(backup).size + + with ( + patch.object(ImageService, "_save_all_sizes", side_effect=OSError("disk full")), + pytest.raises(OSError), + ): + svc.rotate_image(item.image_path, "cw") + + # The photo was not turned, so neither is its backup. + assert Image.open(backup).size == before + + @pytest.mark.asyncio + async def test_rotation_turns_the_backup_too(self, item: ClothingItem): + svc = ImageService() + with patch( + "app.services.background_removal.get_provider", return_value=_recording_provider([]) + ): + result = svc.remove_background(item.image_path) + backup_full = svc.get_image_path(result["original_backup_path"]) + with Image.open(backup_full) as before: + width, height = before.size + + svc.rotate_image(item.image_path, "cw") + + with Image.open(backup_full) as after: + assert after.size == (height, width) diff --git a/backend/tests/test_photo_views_and_accessories.py b/backend/tests/test_photo_views_and_accessories.py new file mode 100644 index 000000000..bb064055d --- /dev/null +++ b/backend/tests/test_photo_views_and_accessories.py @@ -0,0 +1,413 @@ +from functools import partial +from io import BytesIO +from unittest.mock import patch +from uuid import UUID + +import pytest +from httpx import AsyncClient +from PIL import Image, ImageDraw + +from app.api.analytics import TypeDistribution, category_distribution +from app.services.image_service import ImageService +from app.utils.garment_vocabulary import ( + CATEGORIES, + ROLE_CATEGORY, + ROLES, + TYPE_CATEGORY, + TYPES_BY_CATEGORY, +) + +JEWELRY = ("ring", "necklace", "watch", "bracelet", "earrings") + + +def _image_bytes(seed: int) -> bytes: + img = Image.new("RGB", (128, 128), (15, 15, 20)) + draw = ImageDraw.Draw(img) + for bit in range(16): + if (seed >> bit) & 1: + x, y = (bit % 4) * 32, (bit // 4) * 32 + draw.rectangle([x, y, x + 30, y + 30], fill=(235, 235, 240)) + buf = BytesIO() + img.save(buf, format="JPEG") + return buf.getvalue() + + +@pytest.fixture +def storage(tmp_path): + with patch("app.api.items.ImageService", partial(ImageService, storage_path=str(tmp_path))): + yield tmp_path + + +async def _create(client: AsyncClient, headers, seed: int, **form) -> dict: + data = {"skip_ai": "true", **form} + resp = await client.post( + "/api/v1/items", + files={"image": (f"{seed}.jpg", _image_bytes(seed), "image/jpeg")}, + data=data, + headers=headers, + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +async def _add_photo(client: AsyncClient, headers, item_id: str, seed: int, view=None): + data = {"view": view} if view else {} + return await client.post( + f"/api/v1/items/{item_id}/images", + files={"image": (f"{seed}.jpg", _image_bytes(seed), "image/jpeg")}, + data=data, + headers=headers, + ) + + +async def _get(client: AsyncClient, headers, item_id: str) -> dict: + resp = await client.get(f"/api/v1/items/{item_id}", headers=headers) + assert resp.status_code == 200, resp.text + return resp.json() + + +class TestCategories: + def test_every_role_is_in_exactly_one_category(self): + assert set(ROLE_CATEGORY) == set(ROLES) + assert set(ROLE_CATEGORY.values()) == set(CATEGORIES) + + def test_jewelry_types_are_accessories(self): + for item_type in JEWELRY: + assert TYPE_CATEGORY[item_type] == "accessories" + assert item_type in TYPES_BY_CATEGORY["accessories"] + assert TYPE_CATEGORY["shirt"] == "clothing" + assert TYPE_CATEGORY["sneakers"] == "footwear" + + def test_category_distribution_lists_every_section(self): + types = [ + TypeDistribution(type="shirt", count=3, percentage=0), + TypeDistribution(type="ring", count=1, percentage=0), + ] + result = {c.category: (c.count, c.percentage) for c in category_distribution(types)} + assert result == {"clothing": (3, 75.0), "footwear": (0, 0), "accessories": (1, 25.0)} + + @pytest.mark.asyncio + async def test_items_report_and_filter_by_category( + self, client: AsyncClient, auth_headers, storage + ): + ring = await _create(client, auth_headers, 1, type="ring", brand="Pandora") + shirt = await _create(client, auth_headers, 2, type="shirt") + assert ring["category"] == "accessories" + assert shirt["category"] == "clothing" + + resp = await client.get("/api/v1/items?category=accessories", headers=auth_headers) + assert resp.status_code == 200, resp.text + assert [i["id"] for i in resp.json()["items"]] == [ring["id"]] + + resp = await client.get("/api/v1/items?category=hats", headers=auth_headers) + assert resp.status_code == 422 + + +class TestPhotoViews: + @pytest.mark.asyncio + async def test_old_items_have_no_views(self, client: AsyncClient, auth_headers, storage): + item = await _create(client, auth_headers, 3) + assert item["image_view"] is None + assert item["additional_images"] == [] + assert (await _get(client, auth_headers, item["id"]))["image_view"] is None + + @pytest.mark.asyncio + async def test_front_and_back_follow_the_photo( + self, client: AsyncClient, auth_headers, storage + ): + item = await _create(client, auth_headers, 4, image_view="front") + assert item["image_view"] == "front" + + resp = await _add_photo(client, auth_headers, item["id"], 5, view="back") + assert resp.status_code == 201, resp.text + back = resp.json() + assert back["view"] == "back" + + # The back becomes the cover: both labels move with their photos. + resp = await client.post( + f"/api/v1/items/{item['id']}/images/{back['id']}/set-primary", headers=auth_headers + ) + assert resp.status_code == 200, resp.text + swapped = await _get(client, auth_headers, item["id"]) + assert swapped["image_view"] == "back" + assert swapped["additional_images"][0]["view"] == "front" + + @pytest.mark.asyncio + async def test_a_second_front_takes_the_label(self, client: AsyncClient, auth_headers, storage): + item = await _create(client, auth_headers, 6, image_view="front") + extra = (await _add_photo(client, auth_headers, item["id"], 7)).json() + assert extra["view"] is None + + resp = await client.patch( + f"/api/v1/items/{item['id']}/photo-view", + json={"image_id": extra["id"], "view": "front"}, + headers=auth_headers, + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["image_view"] is None + assert data["additional_images"][0]["view"] == "front" + + # Details can repeat, and null clears a label. + resp = await client.patch( + f"/api/v1/items/{item['id']}/photo-view", + json={"image_id": None, "view": "detail"}, + headers=auth_headers, + ) + assert resp.json()["image_view"] == "detail" + resp = await client.patch( + f"/api/v1/items/{item['id']}/photo-view", + json={"image_id": extra["id"], "view": None}, + headers=auth_headers, + ) + assert resp.json()["additional_images"][0]["view"] is None + + @pytest.mark.asyncio + async def test_bad_view_and_unknown_photo_are_rejected( + self, client: AsyncClient, auth_headers, storage + ): + item = await _create(client, auth_headers, 8) + resp = await client.patch( + f"/api/v1/items/{item['id']}/photo-view", + json={"view": "side"}, + headers=auth_headers, + ) + assert resp.status_code == 422 + resp = await client.patch( + f"/api/v1/items/{item['id']}/photo-view", + json={"image_id": item["id"], "view": "back"}, + headers=auth_headers, + ) + assert resp.status_code == 404 + + @pytest.mark.asyncio + async def test_reorder_keeps_views(self, client: AsyncClient, auth_headers, storage): + item = await _create(client, auth_headers, 9) + first = (await _add_photo(client, auth_headers, item["id"], 10, view="back")).json() + second = (await _add_photo(client, auth_headers, item["id"], 11, view="detail")).json() + + resp = await client.patch( + f"/api/v1/items/{item['id']}/images/reorder", + json={"image_ids": [second["id"], first["id"]]}, + headers=auth_headers, + ) + assert resp.status_code == 200, resp.text + assert [(i["id"], i["view"]) for i in resp.json()] == [ + (second["id"], "detail"), + (first["id"], "back"), + ] + + +class TestPhotoLimitsAndStorage: + @pytest.mark.asyncio + async def test_up_to_ten_photos_counting_the_cover( + self, client: AsyncClient, auth_headers, storage + ): + item = await _create(client, auth_headers, 12) + for seed in range(13, 22): + resp = await _add_photo(client, auth_headers, item["id"], seed) + assert resp.status_code == 201, resp.text + resp = await _add_photo(client, auth_headers, item["id"], 22) + assert resp.status_code == 400 + assert "10 photos" in resp.json()["detail"] + + @pytest.mark.asyncio + async def test_deleting_a_photo_removes_its_files_and_label( + self, client: AsyncClient, auth_headers, storage + ): + item = await _create(client, auth_headers, 23) + photo = (await _add_photo(client, auth_headers, item["id"], 24, view="back")).json() + assert (storage / photo["image_path"]).exists() + + resp = await client.delete( + f"/api/v1/items/{item['id']}/images/{photo['id']}", headers=auth_headers + ) + assert resp.status_code == 204 + assert not (storage / photo["image_path"]).exists() + data = await _get(client, auth_headers, item["id"]) + assert data["additional_images"] == [] + assert data["image_view"] is None + assert (storage / item["image_path"]).exists() + + @pytest.mark.asyncio + async def test_deleting_an_item_removes_every_photo( + self, client: AsyncClient, auth_headers, storage + ): + item = await _create(client, auth_headers, 25, image_view="front") + photo = (await _add_photo(client, auth_headers, item["id"], 26, view="back")).json() + + resp = await client.delete(f"/api/v1/items/{item['id']}", headers=auth_headers) + assert resp.status_code == 204 + assert not (storage / item["image_path"]).exists() + assert not (storage / photo["image_path"]).exists() + assert not (storage / photo["thumbnail_path"]).exists() + + +class TestSelectAllKeepsFilters: + @pytest.mark.asyncio + async def test_bulk_delete_of_a_filtered_selection_spares_the_rest( + self, client: AsyncClient, auth_headers, storage + ): + await _create(client, auth_headers, 30, type="ring") + await _create(client, auth_headers, 31, type="shirt", favorite="true") + jeans = await _create(client, auth_headers, 32, type="jeans") + + resp = await client.post( + "/api/v1/items/bulk/delete", + json={"select_all": True, "filters": {"category": "accessories"}}, + headers=auth_headers, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["deleted"] == 1 + + resp = await client.post( + "/api/v1/items/bulk/delete", + json={"select_all": True, "filters": {"favorite": True}}, + headers=auth_headers, + ) + assert resp.json()["deleted"] == 1 + + left = (await client.get("/api/v1/items", headers=auth_headers)).json()["items"] + assert [i["id"] for i in left] == [jeans["id"]] + + +def _redis_pool(): + from unittest.mock import AsyncMock, MagicMock + + job = MagicMock() + job.job_id = "photo-job" + pool = AsyncMock() + pool.enqueue_job.return_value = job + return pool + + +class TestBackgroundRemovalOnEveryPhoto: + @pytest.mark.asyncio + async def test_each_added_photo_gets_its_own_job( + self, client: AsyncClient, auth_headers, storage + ): + from unittest.mock import AsyncMock + + item = await _create(client, auth_headers, 40, image_view="front") + pool = _redis_pool() + with patch("app.api.items.create_pool", AsyncMock(return_value=pool)): + first = await client.post( + f"/api/v1/items/{item['id']}/images", + files={"image": ("b.jpg", _image_bytes(41), "image/jpeg")}, + data={"view": "back", "remove_background": "true"}, + headers=auth_headers, + ) + second = await client.post( + f"/api/v1/items/{item['id']}/images", + files={"image": ("d.jpg", _image_bytes(42), "image/jpeg")}, + data={"view": "detail", "remove_background": "true"}, + headers=auth_headers, + ) + assert first.json()["status"] == "processing" + assert second.json()["status"] == "processing" + calls = [c.args for c in pool.enqueue_job.call_args_list] + assert calls == [ + ("remove_photo_background_job", first.json()["id"], "#FFFFFF"), + ("remove_photo_background_job", second.json()["id"], "#FFFFFF"), + ] + + @pytest.mark.asyncio + async def test_the_job_cleans_one_photo_and_a_failure_stays_on_it( + self, client: AsyncClient, auth_headers, storage, db_session + ): + from unittest.mock import AsyncMock, MagicMock + + from PIL import Image as PILImage + + from app.models.item import ItemImage + from app.workers.background_removal import remove_photo_background_job + + item = await _create(client, auth_headers, 43) + good = (await _add_photo(client, auth_headers, item["id"], 44, view="back")).json() + bad = (await _add_photo(client, auth_headers, item["id"], 45, view="detail")).json() + + ok = MagicMock() + ok.remove.side_effect = lambda img: PILImage.new("RGBA", img.size, (0, 0, 255, 255)) + broken = MagicMock() + broken.remove.side_effect = RuntimeError("model missing") + worker_patches = ( + patch("app.workers.background_removal.get_db_session", return_value=db_session), + patch.object(db_session, "close", AsyncMock()), + patch( + "app.workers.background_removal.ImageService", + partial(ImageService, storage_path=str(storage)), + ), + ) + with worker_patches[0], worker_patches[1], worker_patches[2]: + with patch("app.services.background_removal.get_provider", return_value=ok): + assert (await remove_photo_background_job({}, good["id"], "#FFFFFF"))[ + "status" + ] == "success" + with patch("app.services.background_removal.get_provider", return_value=broken): + assert (await remove_photo_background_job({}, bad["id"], "#FFFFFF"))[ + "status" + ] == "error" + + db_session.expire_all() + cleaned = await db_session.get(ItemImage, UUID(good["id"])) + failed = await db_session.get(ItemImage, UUID(bad["id"])) + assert cleaned.status == "ready" + assert cleaned.original_image_path and (storage / cleaned.original_image_path).exists() + assert failed.status == "error" + assert failed.original_image_path is None + # The cover was never touched by either job. + cover = await _get(client, auth_headers, item["id"]) + assert cover["original_image_path"] is None + + resp = await client.post( + f"/api/v1/items/{item['id']}/images/{good['id']}/restore-original", + headers=auth_headers, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["original_image_path"] is None + assert resp.json()["view"] == "back" + + @pytest.mark.asyncio + async def test_bulk_removal_includes_the_other_photos( + self, client: AsyncClient, auth_headers, storage + ): + from unittest.mock import AsyncMock + + item = await _create(client, auth_headers, 46) + await _add_photo(client, auth_headers, item["id"], 47, view="back") + await _add_photo(client, auth_headers, item["id"], 48, view="detail") + pool = _redis_pool() + with patch("app.api.items.create_pool", AsyncMock(return_value=pool)): + resp = await client.post( + "/api/v1/items/bulk/remove-background", + json={"item_ids": [item["id"]]}, + headers=auth_headers, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["queued"] == 1 + assert resp.json()["photos_queued"] == 2 + names = [c.args[0] for c in pool.enqueue_job.call_args_list] + assert names.count("remove_item_background_job") == 1 + assert names.count("remove_photo_background_job") == 2 + + @pytest.mark.asyncio + async def test_a_photo_being_processed_cannot_become_the_cover( + self, client: AsyncClient, auth_headers, storage + ): + from unittest.mock import AsyncMock + + item = await _create(client, auth_headers, 49) + pool = _redis_pool() + with patch("app.api.items.create_pool", AsyncMock(return_value=pool)): + photo = ( + await client.post( + f"/api/v1/items/{item['id']}/images", + files={"image": ("b.jpg", _image_bytes(50), "image/jpeg")}, + data={"remove_background": "true"}, + headers=auth_headers, + ) + ).json() + resp = await client.post( + f"/api/v1/items/{item['id']}/images/{photo['id']}/set-primary", headers=auth_headers + ) + assert resp.status_code == 409 diff --git a/backend/tests/test_remove_bg_endpoint.py b/backend/tests/test_remove_bg_endpoint.py index 9750529c5..32f10839e 100644 --- a/backend/tests/test_remove_bg_endpoint.py +++ b/backend/tests/test_remove_bg_endpoint.py @@ -9,7 +9,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.models.item import ClothingItem, ItemStatus +from app.models.item import ClothingItem, ItemImage, ItemStatus from app.models.user import User from app.schemas.item import RemoveBackgroundRequest from app.services.image_service import ImageService @@ -23,6 +23,35 @@ def _jpeg_bytes(size: tuple[int, int] = (400, 600)) -> bytes: return buf.getvalue() +def _inline_queue(db_session): + """Stands in for redis + the image worker: the queued removal runs right away.""" + from unittest.mock import AsyncMock + + from app.workers.background_removal import ( + remove_item_background_job, + remove_photo_background_job, + ) + + jobs = { + "remove_item_background_job": remove_item_background_job, + "remove_photo_background_job": remove_photo_background_job, + } + + async def enqueue(name, target_id, color, *args, **kwargs): + with ( + patch("app.workers.background_removal.get_db_session", return_value=db_session), + patch.object(db_session, "close", AsyncMock()), + ): + await jobs[name]({}, target_id, color) + job = MagicMock() + job.job_id = "inline-job" + return job + + pool = AsyncMock() + pool.enqueue_job.side_effect = enqueue + return patch("app.api.items.create_pool", AsyncMock(return_value=pool)) + + def _mock_provider(): provider = MagicMock() provider.remove.side_effect = lambda img: Image.new("RGBA", img.size, (*BLUE, 255)) @@ -132,7 +161,10 @@ async def test_409_when_live_job_owns_the_item( ) item_id = item.id - with patch("app.services.background_removal.get_provider", return_value=_mock_provider()): + with ( + patch("app.services.background_removal.get_provider", return_value=_mock_provider()), + _inline_queue(db_session), + ): response = await client.post( f"/api/v1/items/{item_id}/remove-background", json={}, @@ -153,7 +185,10 @@ async def test_orphaned_processing_item_not_blocked( ai_job_id=None, ) - with patch("app.services.background_removal.get_provider", return_value=_mock_provider()): + with ( + patch("app.services.background_removal.get_provider", return_value=_mock_provider()), + _inline_queue(db_session), + ): response = await client.post( f"/api/v1/items/{item.id}/remove-background", json={}, @@ -173,7 +208,10 @@ async def test_error_kind_item_recovers_to_ready( ) item_id = item.id - with patch("app.services.background_removal.get_provider", return_value=_mock_provider()): + with ( + patch("app.services.background_removal.get_provider", return_value=_mock_provider()), + _inline_queue(db_session), + ): response = await client.post( f"/api/v1/items/{item_id}/remove-background", json={}, @@ -197,7 +235,10 @@ async def test_ordinary_ready_item_unaffected( item = await _make_item(db_session, test_user, status=ItemStatus.ready) item_id = item.id - with patch("app.services.background_removal.get_provider", return_value=_mock_provider()): + with ( + patch("app.services.background_removal.get_provider", return_value=_mock_provider()), + _inline_queue(db_session), + ): response = await client.post( f"/api/v1/items/{item_id}/remove-background", json={}, @@ -213,6 +254,44 @@ async def test_ordinary_ready_item_unaffected( assert refreshed.status == ItemStatus.ready assert refreshed.processing_kind is None + @pytest.mark.asyncio + async def test_other_photos_get_their_background_removed_too( + self, client: AsyncClient, test_user, auth_headers, db_session: AsyncSession + ): + # The item's button used to clean the cover only: a second photo kept its background. + item = await _make_item(db_session, test_user) + paths = await ImageService().process_and_store( + user_id=test_user.id, image_data=_jpeg_bytes(), original_filename="back.jpg" + ) + extra = ItemImage( + item_id=item.id, + image_path=paths["image_path"], + medium_path=paths["medium_path"], + thumbnail_path=paths["thumbnail_path"], + position=0, + ) + db_session.add(extra) + await db_session.commit() + extra_id = extra.id + + with ( + patch("app.services.background_removal.get_provider", return_value=_mock_provider()), + _inline_queue(db_session), + ): + response = await client.post( + f"/api/v1/items/{item.id}/remove-background", + json={}, + headers=auth_headers, + ) + + assert response.status_code == 200 + db_session.expire_all() + refreshed = ( + await db_session.execute(select(ItemImage).where(ItemImage.id == extra_id)) + ).scalar_one() + assert refreshed.status == "ready" + assert refreshed.original_image_path + class TestHealthFeatures: @pytest.mark.asyncio diff --git a/backend/tests/test_upload_background.py b/backend/tests/test_upload_background.py new file mode 100644 index 000000000..e6913eff8 --- /dev/null +++ b/backend/tests/test_upload_background.py @@ -0,0 +1,152 @@ +"""Uploads that ask for the background to be removed: the image worker cleans the photo and then +queues the tagging itself, also when the removal fails.""" + +from io import BytesIO +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from httpx import AsyncClient +from PIL import Image +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.item import ClothingItem, ItemStatus +from app.models.user import User +from app.services.image_service import ImageService +from app.workers.background_removal import remove_item_background_job + + +def _jpeg() -> bytes: + buffer = BytesIO() + Image.new("RGB", (400, 600), (10, 200, 30)).save(buffer, format="JPEG") + return buffer.getvalue() + + +def _redis(job_id: str = "job-1") -> AsyncMock: + redis = AsyncMock() + redis.enqueue_job.return_value = MagicMock(job_id=job_id) + return redis + + +async def _stored_item(db_session: AsyncSession, user: User) -> ClothingItem: + paths = await ImageService().process_and_store( + user_id=user.id, image_data=_jpeg(), original_filename="t.jpg" + ) + item = ClothingItem( + user_id=user.id, + type="unknown", + image_path=paths["image_path"], + thumbnail_path=paths["thumbnail_path"], + medium_path=paths["medium_path"], + status=ItemStatus.processing, + processing_kind="background_removal", + ) + db_session.add(item) + await db_session.commit() + return item + + +class TestUploadQueuesRemovalFirst: + @pytest.mark.asyncio + @pytest.mark.parametrize( + "endpoint,field", [("/api/v1/items", "image"), ("/api/v1/items/bulk", "images")] + ) + async def test_removal_then_tagging(self, client: AsyncClient, auth_headers, endpoint, field): + redis = _redis() + with patch("app.api.items.create_pool", AsyncMock(return_value=redis)): + response = await client.post( + endpoint, + files=[(field, ("shirt.jpg", _jpeg(), "image/jpeg"))], + data={"remove_background": "true"}, + headers=auth_headers, + ) + assert response.status_code == 201, response.json() + name, item_id, color, path = redis.enqueue_job.call_args.args + assert name == "remove_item_background_job" + assert color == "#FFFFFF" + assert path.endswith(".jpg") + assert redis.enqueue_job.call_args.kwargs["_queue_name"] == "arq:images" + + @pytest.mark.asyncio + async def test_without_the_option_only_tags(self, client: AsyncClient, auth_headers): + redis = _redis() + with patch("app.api.items.create_pool", AsyncMock(return_value=redis)): + response = await client.post( + "/api/v1/items/bulk", + files=[("images", ("shirt.jpg", _jpeg(), "image/jpeg"))], + headers=auth_headers, + ) + assert response.status_code == 201 + redis.enqueue_job.assert_called_once() + name, _, path = redis.enqueue_job.call_args.args + assert name == "tag_item_image" + assert path.endswith(".jpg") + assert redis.enqueue_job.call_args.kwargs["_queue_name"] == "arq:tagging" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "endpoint,field", [("/api/v1/items", "image"), ("/api/v1/items/bulk", "images")] + ) + async def test_removal_runs_without_tagging( + self, client: AsyncClient, auth_headers, endpoint, field + ): + # Tagging skipped: the background still goes, and the worker is told not to tag. + redis = _redis() + with patch("app.api.items.create_pool", AsyncMock(return_value=redis)): + response = await client.post( + endpoint, + files=[(field, ("shirt.jpg", _jpeg(), "image/jpeg"))], + data={"remove_background": "true", "skip_ai": "true"}, + headers=auth_headers, + ) + assert response.status_code == 201, response.json() + redis.enqueue_job.assert_called_once() + name, _, _, then_tag = redis.enqueue_job.call_args.args + assert name == "remove_item_background_job" + assert then_tag is None + + +class TestWorkerChainsTagging: + @pytest.mark.asyncio + async def test_success_queues_tagging(self, db_session: AsyncSession, test_user): + item = await _stored_item(db_session, test_user) + redis = _redis("tag-job") + provider = MagicMock() + provider.remove.side_effect = lambda img: Image.new("RGBA", img.size, (0, 0, 255, 255)) + with ( + patch("app.workers.background_removal.get_db_session", return_value=db_session), + patch.object(db_session, "close", AsyncMock()), + patch("app.services.background_removal.get_provider", return_value=provider), + ): + result = await remove_item_background_job( + {"redis": redis}, str(item.id), "#FFFFFF", "/data/x.jpg" + ) + assert result["status"] == "success" and result["tagging"] is True + redis.enqueue_job.assert_called_once_with( + "tag_item_image", str(item.id), "/data/x.jpg", _queue_name="arq:tagging" + ) + refreshed = await db_session.scalar(select(ClothingItem).where(ClothingItem.id == item.id)) + assert refreshed.status == ItemStatus.processing + assert refreshed.processing_kind is None + assert refreshed.ai_job_id == "tag-job" + assert refreshed.original_image_path + + @pytest.mark.asyncio + async def test_failed_removal_still_tags(self, db_session: AsyncSession, test_user): + item = await _stored_item(db_session, test_user) + redis = _redis("tag-job") + provider = MagicMock() + provider.remove.side_effect = RuntimeError("model missing") + with ( + patch("app.workers.background_removal.get_db_session", return_value=db_session), + patch.object(db_session, "close", AsyncMock()), + patch("app.services.background_removal.get_provider", return_value=provider), + ): + result = await remove_item_background_job( + {"redis": redis}, str(item.id), "#FFFFFF", "/data/x.jpg" + ) + assert result["status"] == "error" and result["tagging"] is True + assert redis.enqueue_job.call_args.args[0] == "tag_item_image" + refreshed = await db_session.scalar(select(ClothingItem).where(ClothingItem.id == item.id)) + assert refreshed.status == ItemStatus.processing + assert refreshed.original_image_path is None diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 887df9c33..ebd1b996b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -148,7 +148,7 @@ services: <<: *backend-env IMAGE_WORKER_CONCURRENCY: ${IMAGE_WORKER_CONCURRENCY:-1} BG_REMOVAL_PROVIDER: ${BG_REMOVAL_PROVIDER:-rembg} - BG_REMOVAL_MODEL: ${BG_REMOVAL_MODEL:-u2net} + BG_REMOVAL_MODEL: ${BG_REMOVAL_MODEL:-birefnet-general-lite} BG_REMOVAL_URL: ${BG_REMOVAL_URL:-} BG_REMOVAL_API_KEY: ${BG_REMOVAL_API_KEY:-} volumes: diff --git a/docker-compose.yml b/docker-compose.yml index fd00d17a0..0319813e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -158,9 +158,13 @@ services: <<: *backend-env IMAGE_WORKER_CONCURRENCY: ${IMAGE_WORKER_CONCURRENCY:-1} BG_REMOVAL_PROVIDER: ${BG_REMOVAL_PROVIDER:-rembg} - BG_REMOVAL_MODEL: ${BG_REMOVAL_MODEL:-u2net} + BG_REMOVAL_MODEL: ${BG_REMOVAL_MODEL:-birefnet-general-lite} BG_REMOVAL_URL: ${BG_REMOVAL_URL:-} BG_REMOVAL_API_KEY: ${BG_REMOVAL_API_KEY:-} + # Background removal is the only model the app runs itself (CPU, no GPU). Cap its + # threads and free its memory after a while without photos. + OMP_NUM_THREADS: ${BG_REMOVAL_THREADS:-2} + BG_REMOVAL_IDLE_SECONDS: ${BG_REMOVAL_IDLE_SECONDS:-600} volumes: - wardrobe_data:/data/wardrobe - rembg_models:/opt/rembg/models diff --git a/frontend/app/dashboard/analytics/page.tsx b/frontend/app/dashboard/analytics/page.tsx index d4e50d035..fa1891318 100644 --- a/frontend/app/dashboard/analytics/page.tsx +++ b/frontend/app/dashboard/analytics/page.tsx @@ -13,39 +13,89 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { Progress } from '@/components/ui/progress'; -import { useAnalytics } from '@/lib/hooks/use-analytics'; +import { useAnalytics, type AnalyticsData } from '@/lib/hooks/use-analytics'; import { AcceptanceTrendChart } from '@/components/acceptance-trend-chart'; -import { useColorLabel, useTypeLabel } from '@/lib/hooks/use-translated-constants'; +import { useCategoryLabel, useColorLabel, useTypeLabel } from '@/lib/hooks/use-translated-constants'; +import { CATEGORY_ICONS, CATEGORY_STYLES } from '@/components/item-photos/category-badge'; +import { cn } from '@/lib/utils'; import { useAnalyticsInsightLines } from '@/lib/hooks/use-insight-text'; import { colorSwatch } from '@/lib/colors'; import Image from 'next/image'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; +// Tinted icon badges for the stat cards, from the palette tokens in globals.css. +const STAT_TONES = { + primary: 'bg-primary/10 text-primary', + success: 'bg-success/10 text-success', + info: 'bg-info/10 text-info', + warning: 'bg-warning/10 text-warning', +} as const; + +function CategoryBreakdown({ data }: { data: NonNullable }) { + const t = useTranslations('analytics'); + const label = useCategoryLabel(); + return ( + + + {t('categories.title')} + {t('categories.description')} + + +
+ {data.map((entry) => { + const Icon = CATEGORY_ICONS[entry.category]; + const style = CATEGORY_STYLES[entry.category]; + return ( +
+
+ + + {entry.count} +
+
+
+
+

{t('percent', { value: entry.percentage })}

+
+ ); + })} +
+ + + ); +} + function StatCard({ title, value, description, icon: Icon, trend, + tone = 'primary', }: { title: string; value: string | number; description: string; icon: React.ComponentType<{ className?: string }>; trend?: 'up' | 'down' | 'neutral'; + tone?: keyof typeof STAT_TONES; }) { return ( {title} - + + +
{value}

- {trend === 'up' && } - {trend === 'down' && } + {trend === 'up' && } + {trend === 'down' && } {description}

@@ -131,7 +181,7 @@ function ItemCard({ item }: { item: { id: string; name: string | null; type: str src={item.thumbnail_url} alt={item.name || typeLabel(item.type)} fill - className="object-cover" + className="garment-fit" sizes="48px" /> ) : ( @@ -175,7 +225,7 @@ export default function AnalyticsPage() { ); } - const { wardrobe, color_distribution, type_distribution, most_worn, least_worn, never_worn, acceptance_trend } = data; + const { wardrobe, color_distribution, type_distribution, category_distribution, most_worn, least_worn, never_worn, acceptance_trend } = data; return (
@@ -197,12 +247,14 @@ export default function AnalyticsPage() { value={wardrobe.total_outfits} description={t('stats.outfitsGenerated.description', { count: wardrobe.outfits_this_week })} icon={Sparkles} + tone="info" /> 50 ? 'up' : undefined} />
+ {category_distribution && wardrobe.total_items > 0 && } + {/* Insights */} {insights.length > 0 && ( diff --git a/frontend/app/dashboard/family/feed/page.tsx b/frontend/app/dashboard/family/feed/page.tsx index 610ced845..9aaf5b756 100644 --- a/frontend/app/dashboard/family/feed/page.tsx +++ b/frontend/app/dashboard/family/feed/page.tsx @@ -92,7 +92,7 @@ function FeedOutfitCard({ src={item.thumbnail_url} alt={item.name || typeLabel(item.type)} fill - className="object-cover" + className="garment-fit" sizes="80px" /> ) : ( diff --git a/frontend/app/dashboard/learning/page.tsx b/frontend/app/dashboard/learning/page.tsx index 6fac4409c..2dfbc17d3 100644 --- a/frontend/app/dashboard/learning/page.tsx +++ b/frontend/app/dashboard/learning/page.tsx @@ -196,7 +196,7 @@ function ItemPairCard({ pair }: { pair: ItemPair }) { src={pair.item1.thumbnail_url} alt={pair.item1.name || pair.item1.type} fill - className="object-cover" + className="garment-fit" sizes="48px" /> ) : ( @@ -219,7 +219,7 @@ function ItemPairCard({ pair }: { pair: ItemPair }) { src={pair.item2.thumbnail_url} alt={pair.item2.name || pair.item2.type} fill - className="object-cover" + className="garment-fit" sizes="48px" /> ) : ( diff --git a/frontend/app/dashboard/outfits/[id]/page.tsx b/frontend/app/dashboard/outfits/[id]/page.tsx index 0eac963e4..6d16997a4 100644 --- a/frontend/app/dashboard/outfits/[id]/page.tsx +++ b/frontend/app/dashboard/outfits/[id]/page.tsx @@ -169,7 +169,7 @@ export default function OutfitDetailPage() { src={(item.thumbnail_url || item.image_url)!} alt={item.name || typeLabel(item.type)} fill - className="object-cover transition-transform group-hover:scale-105" + className="garment-fit transition-transform group-hover:scale-105" sizes="(max-width: 640px) 33vw, 20vw" /> ) : ( diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 04c61de6b..31d909a10 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -218,7 +218,7 @@ function PendingOutfitsCard() { src={item.thumbnail_url} alt={item.name || typeLabel(item.type)} fill - className="object-cover" + className="garment-fit" sizes="40px" /> ) : ( diff --git a/frontend/app/dashboard/suggest/page.tsx b/frontend/app/dashboard/suggest/page.tsx index 52d7f2b41..71154fbeb 100644 --- a/frontend/app/dashboard/suggest/page.tsx +++ b/frontend/app/dashboard/suggest/page.tsx @@ -343,7 +343,7 @@ function OutfitCard({ src={item.thumbnail_url} alt={item.name || typeLabel(item.type)} fill - className="object-cover group-hover:scale-105 transition-transform" + className="garment-fit group-hover:scale-105 transition-transform" sizes="(max-width: 640px) 50vw, 33vw" /> ) : ( @@ -792,7 +792,7 @@ function SuggestContent() { src={(selectedItem.thumbnail_url || selectedItem.image_url)!} alt={selectedItem.name || typeLabel(selectedItem.type)} fill - className="object-cover" + className="garment-fit" sizes="56px" /> ) : ( diff --git a/frontend/app/dashboard/wardrobe/page.tsx b/frontend/app/dashboard/wardrobe/page.tsx index 384c1d4f1..c585dd9a4 100644 --- a/frontend/app/dashboard/wardrobe/page.tsx +++ b/frontend/app/dashboard/wardrobe/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState, useEffect } from 'react'; -import Image from 'next/image'; import { useSearchParams, useRouter } from 'next/navigation'; import { Plus, Search, Heart, Grid3X3, Loader2, AlertCircle, RefreshCw, Droplets, ArrowUpDown, SlidersHorizontal, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -24,16 +23,19 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; import { AddItemDialog } from '@/components/add-item-dialog'; +import { CardPhoto } from '@/components/item-photos/card-photo'; +import { CATEGORY_ICONS, CATEGORY_STYLES, CategoryBadge } from '@/components/item-photos/category-badge'; +import { CategoryTypeSelect } from '@/components/item-photos/category-type-select'; import { AnalysisQueuePanel } from '@/components/analysis-queue-panel'; import { ItemDetailDialog } from '@/components/item-detail-dialog'; import { BulkActionToolbar, BulkSelection } from '@/components/bulk-action-toolbar'; import { useItems, useItem, useItemTypes, useReanalyzeItem, useCancelAnalysis, useBulkDeleteItems, useBulkReanalyzeItems, useBulkCancelAnalysis, useBulkRotateItems, useBulkRemoveBackgroundItems, useRemoveBackground, useTaggingProgress, BulkOperationParams, tagProcessingLabel, formatAnalyzingElapsed, deriveQueueSummary } from '@/lib/hooks/use-items'; import { useUserTimezone } from '@/lib/hooks/use-user'; import { colorSwatch, normalizeColor } from '@/lib/colors'; -import { Item } from '@/lib/types'; -import { useClothingTypes, useClothingColors, useSubtypeLabel, useTypeLabel } from '@/lib/hooks/use-translated-constants'; +import { ITEM_CATEGORIES, categoryOfType, type Item, type ItemCategory } from '@/lib/types'; +import { useCategoryLabel, useClothingColors, useSubtypeLabel, useTypeLabel } from '@/lib/hooks/use-translated-constants'; import { toast } from 'sonner'; -import { formatWornAgo, getWornAgoColorClass } from '@/lib/utils'; +import { cn, formatWornAgo, getWornAgoColorClass } from '@/lib/utils'; import { DEFAULT_PAGE_SIZE } from '@/lib/pagination'; import { useTranslations } from 'next-intl'; @@ -100,19 +102,13 @@ function ItemCard({ return (
{item.thumbnail_url ? ( - {item.name + ) : (
{typeLabel(item.type)} @@ -254,6 +250,7 @@ function ItemCard({ {item.subtype && ` • ${subtypeLabel(item.subtype)}`} {item.tags?.logprobs_confidence != null && ` · ${t('ai.confident', { percent: Math.round(item.tags.logprobs_confidence * 100) })}`}

+
{item.primary_color && ( @@ -328,7 +325,7 @@ export default function WardrobePage() { const userTimezone = useUserTimezone(); const t = useTranslations('wardrobe'); const tc = useTranslations('common'); - const clothingTypes = useClothingTypes(); + const categoryLabel = useCategoryLabel(); const [addDialogOpen, setAddDialogOpen] = useState(false); const [queuePanelOpen, setQueuePanelOpen] = useState(false); const [selection, setSelection] = useState({ @@ -339,6 +336,10 @@ export default function WardrobePage() { const [detailItemId, setDetailItemId] = useState(null); const [search, setSearch] = useState(() => searchParams.get('search') ?? ''); const [typeFilter, setTypeFilter] = useState(() => searchParams.get('type') ?? 'all'); + const [categoryFilter, setCategoryFilter] = useState(() => { + const raw = searchParams.get('category'); + return ITEM_CATEGORIES.includes(raw as ItemCategory) ? (raw as ItemCategory) : 'all'; + }); const [sortIndex, setSortIndex] = useState(() => { const raw = Number(searchParams.get('sort')); return Number.isInteger(raw) && raw >= 0 && raw < SORT_OPTIONS.length ? raw : 0; @@ -393,6 +394,7 @@ export default function WardrobePage() { if (search) params.set('search', search); else params.delete('search'); if (typeFilter !== 'all') params.set('type', typeFilter); else params.delete('type'); + if (categoryFilter !== 'all') params.set('category', categoryFilter); else params.delete('category'); if (sortIndex !== 0) params.set('sort', String(sortIndex)); else params.delete('sort'); if (needsWash) params.set('needsWash', 'true'); else params.delete('needsWash'); if (favoriteFilter) params.set('favorite', 'true'); else params.delete('favorite'); @@ -403,13 +405,14 @@ export default function WardrobePage() { if (next !== searchParams.toString()) { router.replace(next ? `/dashboard/wardrobe?${next}` : '/dashboard/wardrobe', { scroll: false }); } - }, [search, typeFilter, sortIndex, needsWash, favoriteFilter, page, pageSize, searchParams, router]); + }, [search, typeFilter, categoryFilter, sortIndex, needsWash, favoriteFilter, page, pageSize, searchParams, router]); const sortOption = SORT_OPTIONS[sortIndex]; const filters = { search: search || undefined, type: typeFilter !== 'all' ? typeFilter : undefined, + category: categoryFilter !== 'all' ? categoryFilter : undefined, needs_wash: needsWash, favorite: favoriteFilter, is_archived: false, @@ -423,6 +426,15 @@ export default function WardrobePage() { typeFilter !== 'all', ].filter(Boolean).length; + const chooseCategory = (category: ItemCategory | 'all') => { + setCategoryFilter(category); + // A type from another section would leave the grid empty. + if (category !== 'all' && typeFilter !== 'all' && categoryOfType(typeFilter) !== category) { + setTypeFilter('all'); + } + setPage(1); + }; + // Fetch items with automatic polling (faster when items are processing) const { data, isLoading, error } = useItems(filters, page, pageSize); const { data: taggingProgress } = useTaggingProgress(); @@ -464,7 +476,7 @@ export default function WardrobePage() { // Clear selection when filters change (but not page - allow cross-page selection) useEffect(() => { setSelection({ mode: 'none', selectedIds: new Set(), excludedIds: new Set() }); - }, [search, typeFilter, needsWash, favoriteFilter, sortIndex]); + }, [search, typeFilter, categoryFilter, needsWash, favoriteFilter, sortIndex]); const handleRetry = (itemId: string) => { reanalyze.mutate(itemId, { @@ -543,6 +555,7 @@ export default function WardrobePage() { excluded_ids: Array.from(selection.excludedIds), filters: { type: typeFilter !== 'all' ? typeFilter : undefined, + category: categoryFilter !== 'all' ? categoryFilter : undefined, search: search || undefined, needs_wash: needsWash, favorite: favoriteFilter, @@ -773,28 +786,56 @@ export default function WardrobePage() {
+ {/* Wardrobe sections */} +
+ + {ITEM_CATEGORIES.map((category) => { + const Icon = CATEGORY_ICONS[category]; + const active = categoryFilter === category; + return ( + + ); + })} +
+ {/* Expandable filter row */} {showFilters && (
- + allLabel={t('allTypes')} + placeholder={t('allTypes')} + only={categoryFilter !== 'all' ? categoryFilter : undefined} + className="w-[170px] h-8 text-xs" + /> - -

- {isSingleDragActive - ? t('dropzoneActive') - : t('dropzone')} -

-

- {t('formatHint')} -

-
- ) : ( -
- {t('previewAlt')} - -
- )} +
- +
@@ -378,15 +369,23 @@ export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) {
+ { + setRemoveBackground(value); + storeRemoveBackground(value); + }} + /> +
)} diff --git a/frontend/components/analysis-queue-panel.tsx b/frontend/components/analysis-queue-panel.tsx index b9065410e..5b3d0fce1 100644 --- a/frontend/components/analysis-queue-panel.tsx +++ b/frontend/components/analysis-queue-panel.tsx @@ -124,7 +124,7 @@ export function AnalysisQueuePanel({ alt={describe(entry.name, entry.type)} fill sizes="40px" - className="object-cover" + className="garment-fit" /> )} diff --git a/frontend/components/feedback-dialog.tsx b/frontend/components/feedback-dialog.tsx index 40b592311..d723b4b33 100644 --- a/frontend/components/feedback-dialog.tsx +++ b/frontend/components/feedback-dialog.tsx @@ -302,7 +302,7 @@ export function FeedbackDialog({ outfit, open, onClose }: FeedbackDialogProps) { src={item.thumbnail_url || item.image_url || item.image_path} alt={item.name || typeLabel(item.type)} fill - className="object-cover" + className="garment-fit" sizes="(max-width: 640px) 33vw, 25vw" loading="lazy" /> diff --git a/frontend/components/generate-pairings-dialog.tsx b/frontend/components/generate-pairings-dialog.tsx index 68aeec0ee..efe9bc197 100644 --- a/frontend/components/generate-pairings-dialog.tsx +++ b/frontend/components/generate-pairings-dialog.tsx @@ -97,7 +97,7 @@ export function GeneratePairingsDialog({ src={imageUrl} alt={item.name || typeLabel(item.type)} fill - className="object-cover" + className="garment-fit" sizes="64px" /> @@ -162,7 +162,7 @@ export function GeneratePairingsDialog({ src={pairingItem.thumbnail_url} alt={typeLabel(pairingItem.type)} fill - className="object-cover" + className="garment-fit" sizes="32px" /> ) : ( diff --git a/frontend/components/item-detail-dialog.tsx b/frontend/components/item-detail-dialog.tsx index 5d44b2b9e..95ef37e60 100644 --- a/frontend/components/item-detail-dialog.tsx +++ b/frontend/components/item-detail-dialog.tsx @@ -23,11 +23,6 @@ import { Layers, Droplets, ChevronDown, - ChevronLeft, - ChevronRight, - Plus, - Star, - ImageIcon, } from 'lucide-react'; import { Dialog, @@ -60,8 +55,12 @@ import { import { Progress } from '@/components/ui/progress'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { toast } from 'sonner'; -import { useUpdateItem, useDeleteItem, useReanalyzeItem, useRotateImage, useRemoveBackground, useRestoreOriginal, useReplaceItemImage, useLogWear, useLogWash, useWashHistory, useItemWearStats, useItemWearHistory, useAddItemImage, useDeleteItemImage, useSetPrimaryImage } from '@/lib/hooks/use-items'; -import { CLOTHING_SUBTYPES, Item } from '@/lib/types'; +import { useUpdateItem, useDeleteItem, useReanalyzeItem, useRotateImage, useRemoveBackground, useRestoreOriginal, useReplaceItemImage, useLogWear, useLogWash, useWashHistory, useItemWearStats, useItemWearHistory } from '@/lib/hooks/use-items'; +import { CLOTHING_SUBTYPES, categoryOfType, Item } from '@/lib/types'; +import { CategoryBadge } from '@/components/item-photos/category-badge'; +import { CategoryTypeSelect } from '@/components/item-photos/category-type-select'; +import { ItemGallery, type GalleryPhoto } from '@/components/item-photos/item-gallery'; +import { PhotoManager } from '@/components/item-photos/photo-manager'; import { useClothingTypes, useClothingColors, @@ -152,6 +151,12 @@ export function ItemDetailDialog({ item, open, onOpenChange }: ItemDetailDialogP const [showWashHistory, setShowWashHistory] = useState(false); const [showWearHistory, setShowWearHistory] = useState(false); const [activeImageIndex, setActiveImageIndex] = useState(0); + const wasProcessing = useRef(false); + useEffect(() => { + const processing = item?.status === 'processing'; + if (wasProcessing.current && !processing) setImageKey((k) => k + 1); + wasProcessing.current = processing; + }, [item?.status]); const updateItem = useUpdateItem(); const deleteItem = useDeleteItem(); @@ -167,9 +172,6 @@ export function ItemDetailDialog({ item, open, onOpenChange }: ItemDetailDialogP const { data: washHistory } = useWashHistory(item?.id || ''); const { data: wearStats } = useItemWearStats(item?.id || ''); const { data: wearHistory } = useItemWearHistory(item?.id || '', 20); - const addImage = useAddItemImage(); - const deleteImage = useDeleteItemImage(); - const setPrimary = useSetPrimaryImage(); useEffect(() => { if (item) { @@ -275,9 +277,9 @@ export function ItemDetailDialog({ item, open, onOpenChange }: ItemDetailDialogP const handleRemoveBackground = async () => { try { + // Queued on the image worker; the photo refreshes when the item stops processing. await removeBackground.mutateAsync({ id: item.id }); - setImageKey((k) => k + 1); - toast.success(t('actions.backgroundRemoved')); + toast.success(tw('ai.removeBackgroundQueued')); } catch (error) { console.error('Failed to remove background:', error); toast.error(t('actions.backgroundRemoveError')); @@ -308,9 +310,26 @@ export function ItemDetailDialog({ item, open, onOpenChange }: ItemDetailDialogP }; const isAnalyzing = reanalyzeItem.isPending || item.status === 'processing'; + const isRemovingBackground = item.status === 'processing' && item.processing_kind === 'background_removal'; // Use signed URL from backend for better quality in detail view const imageUrl = item.image_url || item.image_path; + // The cover first, then the other photos in their saved order. imageKey busts the browser cache + // after the cover is rotated or replaced in place. + const galleryPhotos: GalleryPhoto[] = [ + { + id: 'primary', + src: `${imageUrl}&v=${imageKey}`, + thumb: item.thumbnail_url ? `${item.thumbnail_url}&v=${imageKey}` : null, + view: item.image_view ?? null, + }, + ...(item.additional_images || []).map((img) => ({ + id: img.id, + src: img.medium_url || img.image_url, + thumb: img.thumbnail_url, + view: img.view ?? null, + })), + ]; const colorInfo = clothingColors.find((c) => c.value === item.primary_color); const typeInfo = clothingTypes.find((type) => type.value === item.type); const unrecognizedType = item.type === 'unknown' ? item.ai_unrecognized_type : null; @@ -505,127 +524,24 @@ export function ItemDetailDialog({ item, open, onOpenChange }: ItemDetailDialogP
{/* Image Gallery */} -
-
- {(() => { - const allImages = [ - { url: `${imageUrl}&v=${imageKey}`, id: 'primary' }, - ...(item.additional_images || []).map((img) => ({ url: img.image_url, id: img.id })), - ]; - const currentImage = allImages[activeImageIndex] || allImages[0]; - return ( - <> - {item.name - {allImages.length > 1 && ( - <> - - -
- {allImages.map((_, idx) => ( -
- - )} - - ); - })()} - {isAnalyzing && ( -
- - {t('view.aiAnalyzing')} -
- )} -
- {/* Thumbnail strip */} - {(item.additional_images?.length > 0 || isEditing) && ( -
- - {(item.additional_images || []).map((img, idx) => ( -
- - {isEditing && ( -
- - -
- )} +
+ + + + {isRemovingBackground ? tw('ai.removingBackground') : t('view.aiAnalyzing')} +
- ))} - {isEditing && (item.additional_images?.length || 0) < 4 && ( - - )} -
- )} + ) : null + } + /> + {isEditing && }
{/* Details */} @@ -648,21 +564,10 @@ export function ItemDetailDialog({ item, open, onOpenChange }: ItemDetailDialogP {t('unrecognizedType', { value: unrecognizedType })}

)} - + />
@@ -772,6 +677,7 @@ export function ItemDetailDialog({ item, open, onOpenChange }: ItemDetailDialogP {item.subtype && ( • {subtypeLabel(item.subtype)} )} +
{unrecognizedType && (

@@ -971,7 +877,7 @@ export function ItemDetailDialog({ item, open, onOpenChange }: ItemDetailDialogP alt={oi.name || typeLabel(oi.type)} width={20} height={20} - className="object-cover w-full h-full" + className="garment-fit w-full h-full" /> )}

diff --git a/frontend/components/item-photos/card-photo.tsx b/frontend/components/item-photos/card-photo.tsx new file mode 100644 index 000000000..952b86129 --- /dev/null +++ b/frontend/components/item-photos/card-photo.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useState } from 'react'; +import Image from 'next/image'; +import { useTranslations } from 'next-intl'; +import { GalleryHorizontal, Repeat2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { Item } from '@/lib/types'; + +const CARD_SIZES = '(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw'; + +// The other side of the cover: its back when the cover is the front (or unlabelled), its front +// when the cover is the back. +function otherSide(item: Item) { + const wanted = item.image_view === 'back' ? 'front' : item.image_view === 'front' || !item.image_view ? 'back' : null; + if (!wanted) return null; + return (item.additional_images ?? []).find((img) => img.view === wanted) ?? null; +} + +// Card image for the wardrobe grid. With both sides photographed, hovering (on devices that can +// hover) or tapping the flip button shows the other side without opening the item. +export function CardPhoto({ item, alt }: { item: Item; alt: string }) { + const t = useTranslations('wardrobe.photos'); + const [flipped, setFlipped] = useState(false); + const other = otherSide(item); + const photoCount = 1 + (item.additional_images?.length ?? 0); + + if (!item.thumbnail_url) return null; + + return ( + <> + {alt} + {other && ( + + )} + {(photoCount > 1 || other) && ( +
+ {photoCount > 1 && ( + + + )} + {other && ( + + )} +
+ )} + + ); +} diff --git a/frontend/components/item-photos/category-badge.tsx b/frontend/components/item-photos/category-badge.tsx new file mode 100644 index 000000000..75a97b43b --- /dev/null +++ b/frontend/components/item-photos/category-badge.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { Footprints, Gem, Shirt } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useCategoryLabel } from '@/lib/hooks/use-translated-constants'; +import type { ItemCategory } from '@/lib/types'; + +// Written out in full so Tailwind can see every class; the colours come from the +// --category-* tokens in globals.css. +export const CATEGORY_STYLES: Record = { + clothing: { + chip: 'border-category-clothing/25 bg-category-clothing/10 text-category-clothing', + active: 'border-category-clothing bg-category-clothing/15 text-category-clothing', + text: 'text-category-clothing', + }, + footwear: { + chip: 'border-category-footwear/25 bg-category-footwear/10 text-category-footwear', + active: 'border-category-footwear bg-category-footwear/15 text-category-footwear', + text: 'text-category-footwear', + }, + accessories: { + chip: 'border-category-accessories/25 bg-category-accessories/10 text-category-accessories', + active: 'border-category-accessories bg-category-accessories/15 text-category-accessories', + text: 'text-category-accessories', + }, +}; + +export const CATEGORY_ICONS: Record = { + clothing: Shirt, + footwear: Footprints, + accessories: Gem, +}; + +export function CategoryBadge({ category, className }: { category: ItemCategory; className?: string }) { + const label = useCategoryLabel(); + const Icon = CATEGORY_ICONS[category]; + return ( + + + ); +} diff --git a/frontend/components/item-photos/category-type-select.tsx b/frontend/components/item-photos/category-type-select.tsx new file mode 100644 index 000000000..05c01c791 --- /dev/null +++ b/frontend/components/item-photos/category-type-select.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { CATEGORY_STYLES } from '@/components/item-photos/category-badge'; +import { useGroupedClothingTypes } from '@/lib/hooks/use-translated-constants'; +import type { ItemCategory } from '@/lib/types'; + +// The item type picker, split into clothing, footwear and accessories. +export function CategoryTypeSelect({ + value, + onValueChange, + placeholder, + id, + allLabel, + only, + className, +}: { + value: string; + onValueChange: (value: string) => void; + placeholder?: string; + id?: string; + // Adds an "all types" entry with the value 'all', for filters. + allLabel?: string; + // Limits the list to one section. + only?: ItemCategory; + className?: string; +}) { + const groups = useGroupedClothingTypes().filter((group) => !only || group.category === only); + return ( + + ); +} diff --git a/frontend/components/item-photos/item-gallery.tsx b/frontend/components/item-photos/item-gallery.tsx new file mode 100644 index 000000000..f8b5fafaa --- /dev/null +++ b/frontend/components/item-photos/item-gallery.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useRef, type ReactNode } from 'react'; +import Image from 'next/image'; +import { useTranslations } from 'next-intl'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { usePhotoTypeLabel } from '@/lib/hooks/use-translated-constants'; +import type { PhotoType } from '@/lib/types'; + +export interface GalleryPhoto { + id: string; + src: string; + thumb?: string | null; + view?: PhotoType | null; +} + +const SWIPE_DISTANCE = 40; + +// The item's photos as a carousel: arrows, swipe, arrow keys, thumbnails, and a front/back +// switch when both are labelled. The index is owned by the caller so other controls (rotate, +// remove background) know which photo is showing. +export function ItemGallery({ + photos, + index, + onIndexChange, + alt, + overlay, +}: { + photos: GalleryPhoto[]; + index: number; + onIndexChange: (index: number) => void; + alt: string; + overlay?: ReactNode; +}) { + const t = useTranslations('wardrobe.photos'); + const viewLabel = usePhotoTypeLabel(); + const swipeStart = useRef(null); + const count = photos.length; + const current = photos[Math.min(index, count - 1)] ?? photos[0]; + const go = (next: number) => onIndexChange((next + count) % count); + const frontIndex = photos.findIndex((photo) => photo.view === 'front'); + const backIndex = photos.findIndex((photo) => photo.view === 'back'); + + if (!current) return null; + + return ( +
+
1 ? 0 : -1} + role={count > 1 ? 'group' : undefined} + aria-roledescription={count > 1 ? 'carousel' : undefined} + aria-label={count > 1 ? t('counter', { current: index + 1, total: count }) : undefined} + onKeyDown={(e) => { + if (count < 2) return; + if (e.key === 'ArrowLeft') go(index - 1); + if (e.key === 'ArrowRight') go(index + 1); + }} + onPointerDown={(e) => { + swipeStart.current = e.clientX; + }} + onPointerUp={(e) => { + if (swipeStart.current === null || count < 2) return; + const distance = e.clientX - swipeStart.current; + swipeStart.current = null; + if (Math.abs(distance) >= SWIPE_DISTANCE) go(distance < 0 ? index + 1 : index - 1); + }} + > + {current.view + {current.view && ( + + {viewLabel(current.view)} + + )} + {count > 1 && ( + <> + + + + {index + 1}/{count} + + + )} + {overlay} +
+ + {frontIndex >= 0 && backIndex >= 0 && ( +
+
+ {[ + { key: 'front', at: frontIndex }, + { key: 'back', at: backIndex }, + ].map(({ key, at }) => ( + + ))} +
+
+ )} + + {count > 1 && ( +
+ {photos.map((photo, idx) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/components/item-photos/photo-manager.tsx b/frontend/components/item-photos/photo-manager.tsx new file mode 100644 index 000000000..5a88cddfa --- /dev/null +++ b/frontend/components/item-photos/photo-manager.tsx @@ -0,0 +1,333 @@ +'use client'; + +import { useState } from 'react'; +import Image from 'next/image'; +import { useTranslations } from 'next-intl'; +import { toast } from 'sonner'; +import { AlertCircle, ArrowLeft, ArrowRight, Eraser, ImagePlus, Loader2, Star, Trash2, Undo2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { getErrorMessage } from '@/lib/api'; +import { + useAddItemImage, + useDeleteItemImage, + useRemovePhotoBackground, + useRestorePhotoOriginal, + useReorderItemImages, + useSetPhotoView, + useSetPrimaryImage, +} from '@/lib/hooks/use-items'; +import { usePhotoTypeLabel } from '@/lib/hooks/use-translated-constants'; +import { ACCEPTED_IMAGE_INPUT } from '@/lib/image-types'; +import { readRemoveBackground } from '@/lib/remove-background-pref'; +import { PHOTO_TYPES, type Item, type PhotoType } from '@/lib/types'; +import { cn } from '@/lib/utils'; + +// Front and back are taken by one photo each; new photos fill whichever is still free. +export function nextFreeView(taken: (PhotoType | null | undefined)[]): PhotoType | null { + if (!taken.includes('front')) return 'front'; + if (!taken.includes('back')) return 'back'; + return null; +} + +interface ManagedPhoto { + // null is the cover, which lives on the item itself. + imageId: string | null; + src: string; + view: PhotoType | null; + status: 'ready' | 'processing' | 'error'; + hasOriginal: boolean; +} + +// Edit-mode photo list for an item: label each photo, pick the cover, reorder, delete, add more. +export function PhotoManager({ item, maxPhotos = 10 }: { item: Item; maxPhotos?: number }) { + const t = useTranslations('wardrobe.photos'); + const viewLabel = usePhotoTypeLabel(); + const addImage = useAddItemImage(); + const deleteImage = useDeleteItemImage(); + const setPrimary = useSetPrimaryImage(); + const reorder = useReorderItemImages(); + const setView = useSetPhotoView(); + const removePhotoBackground = useRemovePhotoBackground(); + const restorePhotoOriginal = useRestorePhotoOriginal(); + const [uploading, setUploading] = useState(0); + const [toDelete, setToDelete] = useState(null); + + const extras = item.additional_images ?? []; + const photos: ManagedPhoto[] = [ + { + imageId: null, + src: item.thumbnail_url || item.image_url || item.image_path, + view: item.image_view ?? null, + status: item.status === 'processing' && item.processing_kind === 'background_removal' ? 'processing' : 'ready', + hasOriginal: !!item.original_image_path, + }, + ...extras.map((img) => ({ + imageId: img.id, + // Removal rewrites the file in place, so a cleaned photo gets its own URL in the cache. + src: `${img.thumbnail_url || img.image_url}${img.status === 'ready' && img.original_image_path ? '&bg=1' : ''}`, + view: img.view ?? null, + status: img.status ?? 'ready', + hasOriginal: !!img.original_image_path, + })), + ]; + const busy = + uploading > 0 || + addImage.isPending || + deleteImage.isPending || + setPrimary.isPending || + reorder.isPending || + setView.isPending || + removePhotoBackground.isPending || + restorePhotoOriginal.isPending; + const room = maxPhotos - photos.length; + + const fail = (error: unknown) => toast.error(getErrorMessage(error, t('error'))); + + const addFiles = async (files: File[]) => { + const accepted = files.slice(0, Math.max(0, room)); + if (files.length > accepted.length) toast.warning(t('limit', { count: maxPhotos })); + const taken = photos.map((photo) => photo.view); + // Same choice as the upload dialog: each new photo gets its own background removal. + const removeBackground = readRemoveBackground(); + setUploading(accepted.length); + for (const file of accepted) { + const view = nextFreeView(taken); + taken.push(view); + try { + await addImage.mutateAsync({ itemId: item.id, file, view, removeBackground }); + } catch (error) { + fail(error); + } finally { + setUploading((n) => n - 1); + } + } + }; + + const move = (position: number, delta: number) => { + const ids = extras.map((img) => img.id); + const target = position + delta; + if (target < 0 || target >= ids.length) return; + [ids[position], ids[target]] = [ids[target], ids[position]]; + reorder.mutate({ itemId: item.id, imageIds: ids }, { onError: fail }); + }; + + const remove = async (photo: ManagedPhoto) => { + try { + if (photo.imageId) { + await deleteImage.mutateAsync({ itemId: item.id, imageId: photo.imageId }); + } else if (extras[0]) { + // The cover cannot be dropped on its own: the first other photo takes its place, then the + // swapped row (now holding the old cover) is deleted. + await setPrimary.mutateAsync({ itemId: item.id, imageId: extras[0].id }); + await deleteImage.mutateAsync({ itemId: item.id, imageId: extras[0].id }); + } + } catch (error) { + fail(error); + } + }; + + return ( +
+
+

{t('title')}

+ + {t('count', { count: photos.length, max: maxPhotos })} + +
+
+ {photos.map((photo, index) => { + const position = index - 1; + return ( +
+
+ {photo.view + {photo.status === 'processing' && ( +
+ + {t('removingBackground')} +
+ )} + {photo.status === 'error' && ( + + + {t('backgroundFailed')} + + )} + {index === 0 && ( + + + {t('cover')} + + )} +
+ +
+ {photo.imageId ? ( + <> + + {photo.hasOriginal ? ( + + ) : ( + + )} + + + + ) : ( + {t('coverHint')} + )} + +
+
+ ); + })} + {room > 0 && ( + + )} +
+ + !open && setToDelete(null)}> + + + {t('removeTitle')} + {t('removeDescription')} + + + {t('keep')} + { + if (toDelete) void remove(toDelete); + setToDelete(null); + }} + > + {t('remove')} + + + + +
+ ); +} diff --git a/frontend/components/mobile-sidebar.tsx b/frontend/components/mobile-sidebar.tsx index 8b312c6a4..29cb5af0b 100644 --- a/frontend/components/mobile-sidebar.tsx +++ b/frontend/components/mobile-sidebar.tsx @@ -100,7 +100,7 @@ export function MobileSidebar({ open, onClose }: MobileSidebarProps) { className={cn( 'group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6', isActive - ? 'bg-primary text-primary-foreground' + ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground' )} > @@ -131,7 +131,7 @@ export function MobileSidebar({ open, onClose }: MobileSidebarProps) { className={cn( 'group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6', isActive - ? 'bg-primary text-primary-foreground' + ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground' )} > diff --git a/frontend/components/outfit-history-card.tsx b/frontend/components/outfit-history-card.tsx index 78fb8b91f..a9670d5a1 100644 --- a/frontend/components/outfit-history-card.tsx +++ b/frontend/components/outfit-history-card.tsx @@ -85,7 +85,7 @@ export function OutfitHistoryCard({ outfit, onFeedback, onPreview }: OutfitHisto src={item.thumbnail_url} alt={item.name || typeLabel(item.type)} fill - className="object-cover" + className="garment-fit" sizes="64px" /> ) : ( @@ -140,7 +140,7 @@ export function OutfitHistoryCard({ outfit, onFeedback, onPreview }: OutfitHisto src={item.thumbnail_url} alt={item.name || typeLabel(item.type)} fill - className="object-cover" + className="garment-fit" sizes="56px" /> ) : ( diff --git a/frontend/components/outfit-preview-dialog.tsx b/frontend/components/outfit-preview-dialog.tsx index c99765ef0..9f914fe89 100644 --- a/frontend/components/outfit-preview-dialog.tsx +++ b/frontend/components/outfit-preview-dialog.tsx @@ -241,7 +241,7 @@ export function OutfitPreviewDialog({ outfit, open, onClose, isOwner = true }: O src={item.thumbnail_url} alt={item.name || typeLabel(item.type)} fill - className="object-cover" + className="garment-fit" sizes="56px" /> ) : ( diff --git a/frontend/components/outfits/outfit-card.tsx b/frontend/components/outfits/outfit-card.tsx index 40583a3dd..dfc05378f 100644 --- a/frontend/components/outfits/outfit-card.tsx +++ b/frontend/components/outfits/outfit-card.tsx @@ -149,7 +149,7 @@ export function OutfitCard({ outfit, onClick, selectMode, selected, onSelect }: src={(item.thumbnail_url || item.image_url)!} alt={item.name || typeLabel(item.type)} fill - className="object-cover" + className="garment-fit" sizes="(max-width: 640px) 25vw, 15vw" loading="lazy" /> diff --git a/frontend/components/pairing-card.tsx b/frontend/components/pairing-card.tsx index 7bb8708c7..19d648cea 100644 --- a/frontend/components/pairing-card.tsx +++ b/frontend/components/pairing-card.tsx @@ -79,7 +79,7 @@ export function PairingCard({ pairing, onFeedback, onPreview }: PairingCardProps src={pairing.source_item.thumbnail_url} alt={pairing.source_item.name || pairing.source_item.type} fill - className="object-cover" + className="garment-fit" sizes="48px" /> ) : ( @@ -118,7 +118,7 @@ export function PairingCard({ pairing, onFeedback, onPreview }: PairingCardProps src={item.thumbnail_url} alt={item.name || typeLabel(item.type)} fill - className="object-cover" + className="garment-fit" sizes="56px" /> ) : ( diff --git a/frontend/components/photo-picker.tsx b/frontend/components/photo-picker.tsx new file mode 100644 index 000000000..4e844ef27 --- /dev/null +++ b/frontend/components/photo-picker.tsx @@ -0,0 +1,200 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { useTranslations } from 'next-intl'; +import { ArrowLeft, ArrowRight, Star, Trash2, Upload } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { nextFreeView } from '@/components/item-photos/photo-manager'; +import { usePhotoTypeLabel } from '@/lib/hooks/use-translated-constants'; +import { ACCEPTED_IMAGE_TYPES } from '@/lib/image-types'; +import { PHOTO_TYPES, type PhotoType } from '@/lib/types'; +import { cn } from '@/lib/utils'; + +export interface DraftPhoto { + id: string; + file: File; + preview: string; + type: PhotoType; +} + +const MAX_PHOTO_BYTES = 20 * 1024 * 1024; + +// Photos for an item that does not exist yet: nothing is uploaded until the form is sent. The +// first photo is the cover; the others are added to the item right after it is created. +export function PhotoPicker({ + photos, + onChange, + disabled = false, + limit = 10, + chooseCover = true, +}: { + photos: DraftPhoto[]; + onChange: (photos: DraftPhoto[]) => void; + disabled?: boolean; + limit?: number; + chooseCover?: boolean; +}) { + const t = useTranslations('wardrobe.photos'); + const viewLabel = usePhotoTypeLabel(); + const ownedUrls = useRef(new Set()); + + // Object URLs are released as soon as their photo leaves the list, and all of them on unmount. + useEffect(() => { + const kept = new Set(photos.map((photo) => photo.preview)); + ownedUrls.current.forEach((url) => { + if (!kept.has(url)) { + URL.revokeObjectURL(url); + ownedUrls.current.delete(url); + } + }); + }, [photos]); + useEffect(() => { + const owned = ownedUrls.current; + return () => owned.forEach((url) => URL.revokeObjectURL(url)); + }, []); + + const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ + accept: ACCEPTED_IMAGE_TYPES, + multiple: true, + maxSize: MAX_PHOTO_BYTES, + disabled: disabled || photos.length >= limit, + onDrop: (files) => { + const taken = photos.map((photo) => photo.type); + const added = files.slice(0, Math.max(0, limit - photos.length)).map((file) => { + const type = nextFreeView(taken) ?? 'other'; + taken.push(type); + const preview = URL.createObjectURL(file); + ownedUrls.current.add(preview); + return { id: crypto.randomUUID(), file, preview, type }; + }); + onChange([...photos, ...added]); + }, + }); + + const move = (from: number, to: number) => { + const next = [...photos]; + const [photo] = next.splice(from, 1); + next.splice(to, 0, photo); + onChange(next); + }; + + const setType = (id: string, type: PhotoType) => + onChange( + photos.map((photo) => { + if (photo.id === id) return { ...photo, type }; + // Only one front and one back: the photo that had the label falls back to "other". + if ((type === 'front' || type === 'back') && photo.type === type) return { ...photo, type: 'other' }; + return photo; + }) + ); + + return ( +
+ {photos.length < limit && ( +
+ + +

{t('add')}

+

{t('pickerHint', { count: limit })}

+
+ )} + {fileRejections.length > 0 && ( +

+ {t('invalid')} +

+ )} + {photos.length > 0 && ( +
+ {photos.map((photo, index) => ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- local object URL preview */} + {photo.file.name} + {chooseCover && index === 0 && ( + + {t('cover')} + + )} +
+ +
+ {chooseCover && ( + + )} + + + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/components/shared/item-picker.tsx b/frontend/components/shared/item-picker.tsx index 87d1421d1..98de28f4b 100644 --- a/frontend/components/shared/item-picker.tsx +++ b/frontend/components/shared/item-picker.tsx @@ -135,7 +135,7 @@ export function ItemPicker({ src={(item.thumbnail_url || item.image_url)!} alt={item.name || typeLabel(item.type)} fill - className="object-cover" + className="garment-fit" sizes="(max-width: 640px) 33vw, 20vw" loading="lazy" /> diff --git a/frontend/components/sidebar.tsx b/frontend/components/sidebar.tsx index 90faf6630..4c290cd32 100644 --- a/frontend/components/sidebar.tsx +++ b/frontend/components/sidebar.tsx @@ -66,7 +66,7 @@ export function Sidebar() { className={cn( 'group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6', isActive - ? 'bg-primary text-primary-foreground' + ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground' )} > @@ -96,7 +96,7 @@ export function Sidebar() { className={cn( 'group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6', isActive - ? 'bg-primary text-primary-foreground' + ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground' )} > diff --git a/frontend/components/studio/canvas-panel.tsx b/frontend/components/studio/canvas-panel.tsx index 2d857a925..27434cfce 100644 --- a/frontend/components/studio/canvas-panel.tsx +++ b/frontend/components/studio/canvas-panel.tsx @@ -55,7 +55,7 @@ export function CanvasPanel({ items, onRemove }: CanvasPanelProps) { alt={item.name || typeLabel(item.type)} width={96} height={96} - className="object-cover w-full h-full" + className="garment-fit w-full h-full" /> ) : (
diff --git a/frontend/lib/generated/garment-vocabulary.ts b/frontend/lib/generated/garment-vocabulary.ts index 7f6aeabde..4e4bb8199 100644 --- a/frontend/lib/generated/garment-vocabulary.ts +++ b/frontend/lib/generated/garment-vocabulary.ts @@ -1,10 +1,26 @@ // Generated from backend/app/data/garment_vocabulary.json by scripts/gen-shared-data.mjs. // Do not edit by hand; run `npm run vocab:gen`. -export const CLOTHING_TYPE_VALUES = ['shirt', 't-shirt', 'top', 'pants', 'jeans', 'shorts', 'dress', 'jumpsuit', 'skirt', 'jacket', 'coat', 'sweater', 'hoodie', 'blazer', 'suit', 'vest', 'cardigan', 'polo', 'blouse', 'tank-top', 'shoes', 'sneakers', 'boots', 'sandals', 'socks', 'tights', 'tie', 'hat', 'scarf', 'belt', 'bag', 'accessories'] as const; +export const CLOTHING_TYPE_VALUES = ['shirt', 't-shirt', 'top', 'pants', 'jeans', 'shorts', 'dress', 'jumpsuit', 'skirt', 'jacket', 'coat', 'sweater', 'hoodie', 'blazer', 'suit', 'vest', 'cardigan', 'polo', 'blouse', 'tank-top', 'shoes', 'sneakers', 'boots', 'sandals', 'socks', 'tights', 'tie', 'hat', 'scarf', 'belt', 'bag', 'accessories', 'ring', 'necklace', 'watch', 'bracelet', 'earrings'] as const; export const MATERIAL_VALUES = ['cotton', 'denim', 'leather', 'wool', 'polyester', 'silk', 'linen', 'knit', 'fleece', 'suede', 'velvet', 'nylon', 'canvas', 'down', 'shearling'] as const; export const FORMALITY_VALUES = ['very-casual', 'casual', 'smart-casual', 'business-casual', 'formal', 'very-formal'] as const; export const OCCASION_VALUES = ['casual', 'office', 'work', 'formal', 'smart-casual', 'business-casual', 'date', 'party', 'sporty', 'sport', 'outdoor', 'travel', 'lounge', 'beach', 'interview', 'wedding', 'dinner', 'brunch', 'gym', 'running', 'hiking', 'weekend'] as const; export const ROLE_VALUES = ['full_body', 'base_top', 'mid_layer', 'suit', 'outer_layer', 'bottom', 'legwear', 'footwear', 'socks', 'neckwear', 'accessory'] as const; +export const CATEGORY_VALUES = ['clothing', 'footwear', 'accessories'] as const; + +// Wardrobe section (clothing, footwear, accessories) of each role. +export const ROLE_CATEGORY: Record = { + full_body: 'clothing', + base_top: 'clothing', + mid_layer: 'clothing', + suit: 'clothing', + outer_layer: 'clothing', + bottom: 'clothing', + legwear: 'clothing', + socks: 'clothing', + footwear: 'footwear', + neckwear: 'accessories', + accessory: 'accessories', +}; export const ITEM_ROLE: Record = { shirt: 'base_top', @@ -39,6 +55,11 @@ export const ITEM_ROLE: Record = { belt: 'accessory', bag: 'accessory', accessories: 'accessory', + ring: 'accessory', + necklace: 'accessory', + watch: 'accessory', + bracelet: 'accessory', + earrings: 'accessory', }; export const CLOTHING_COLORS = [ diff --git a/frontend/lib/hooks/use-analytics.ts b/frontend/lib/hooks/use-analytics.ts index 6fa8dfa94..96427f1ec 100644 --- a/frontend/lib/hooks/use-analytics.ts +++ b/frontend/lib/hooks/use-analytics.ts @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import type { ItemCategory } from '@/lib/types'; import { useSession } from 'next-auth/react'; import { api } from '@/lib/api'; import { useSetTokenIfAvailable } from '@/lib/hooks/use-session-token'; @@ -49,10 +50,18 @@ export interface WardrobeStats { total_wears: number; } +export interface CategoryDistribution { + category: ItemCategory; + count: number; + percentage: number; +} + export interface AnalyticsData { wardrobe: WardrobeStats; color_distribution: ColorDistribution[]; type_distribution: TypeDistribution[]; + // Missing from servers older than the accessories sections. + category_distribution?: CategoryDistribution[]; most_worn: WearStats[]; least_worn: WearStats[]; never_worn: WearStats[]; diff --git a/frontend/lib/hooks/use-items.ts b/frontend/lib/hooks/use-items.ts index b19c70573..81f0b8053 100644 --- a/frontend/lib/hooks/use-items.ts +++ b/frontend/lib/hooks/use-items.ts @@ -5,7 +5,7 @@ import { useQueries, useQuery, useMutation, useQueryClient } from '@tanstack/rea import { useSession } from 'next-auth/react'; import { api, API_BASE_PATH, getAccessToken, ApiError, NetworkError } from '@/lib/api'; import { useSetTokenIfAvailable, applySessionToken } from '@/lib/hooks/use-session-token'; -import { Item, ItemListResponse, ItemFilter, WashHistoryEntry, ItemImage, TaggingProgress } from '@/lib/types'; +import { Item, ItemListResponse, ItemFilter, WashHistoryEntry, ItemImage, PhotoType, TaggingProgress } from '@/lib/types'; import { chunkArray } from '@/lib/utils'; import { enqueueFiles } from '@/lib/upload-queue'; import { startDrain } from '@/lib/upload-manager'; @@ -32,6 +32,7 @@ function itemListQuery(filters: ItemFilter, page: number, pageSize: number) { page_size: String(pageSize), }; if (filters.type) params.type = filters.type; + if (filters.category) params.category = filters.category; if (filters.colors?.length) params.colors = filters.colors.join(','); if (filters.search) params.search = filters.search; if (filters.favorite !== undefined) params.favorite = String(filters.favorite); @@ -44,7 +45,7 @@ function itemListQuery(filters: ItemFilter, page: number, pageSize: number) { return api.get('/items', { params }); }, refetchInterval: (query: { state: { data: ItemListResponse | undefined } }) => - processingPollInterval(!!query.state.data?.items?.some((item) => item.status === 'processing')), + processingPollInterval(!!query.state.data?.items?.some(isProcessing)), // Tagging runs server-side in the worker, so it keeps going while the tab is // hidden. Without this the polling stops and the UI looks frozen, which reads // as "analysis stopped when I switched tabs". @@ -119,9 +120,16 @@ export function useItem(itemId: string) { queryKey: queryKeys.item(itemId), queryFn: () => api.get(`/items/${itemId}`), enabled: !!itemId && status !== 'loading', + refetchInterval: (query: { state: { data: Item | undefined } }) => + processingPollInterval(!!query.state.data && isProcessing(query.state.data)), }); } +// The item or any of its photos still has work in the image worker or the AI queue. +export function isProcessing(item: Item): boolean { + return item.status === 'processing' || (item.additional_images ?? []).some((img) => img.status === 'processing'); +} + export function useCreateItem() { const queryClient = useQueryClient(); const { data: session } = useSession(); @@ -463,10 +471,22 @@ export function useAddItemImage() { const { data: session } = useSession(); return useMutation({ - mutationFn: async ({ itemId, file }: { itemId: string; file: File }) => { + mutationFn: async ({ + itemId, + file, + view, + removeBackground, + }: { + itemId: string; + file: File; + view?: PhotoType | null; + removeBackground?: boolean; + }) => { const token = session?.accessToken || getAccessToken(); const formData = new FormData(); formData.append('image', file); + if (view) formData.append('view', view); + if (removeBackground) formData.append('remove_background', 'true'); const headers: Record = {}; if (token) { @@ -508,6 +528,68 @@ export function useDeleteItemImage() { }); } +// Labels one photo as front, back, detail or other; imageId null is the cover photo. +export function useSetPhotoView() { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + + return useMutation({ + mutationFn: async ({ itemId, imageId, view }: { itemId: string; imageId: string | null; view: PhotoType | null }) => { + applySessionToken(session); + return api.patch(`/items/${itemId}/photo-view`, { image_id: imageId, view }); + }, + onSuccess: (_, variables) => { + invalidateItemCaches(queryClient, variables.itemId); + }, + }); +} + +// Background removal on one additional photo (queued on the image worker), and its undo. +export function useRemovePhotoBackground() { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + + return useMutation({ + mutationFn: async ({ itemId, imageId }: { itemId: string; imageId: string }) => { + applySessionToken(session); + return api.post(`/items/${itemId}/images/${imageId}/remove-background`, { bg_color: '#FFFFFF' }); + }, + onSuccess: (_, variables) => { + invalidateItemCaches(queryClient, variables.itemId); + }, + }); +} + +export function useRestorePhotoOriginal() { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + + return useMutation({ + mutationFn: async ({ itemId, imageId }: { itemId: string; imageId: string }) => { + applySessionToken(session); + return api.post(`/items/${itemId}/images/${imageId}/restore-original`); + }, + onSuccess: (_, variables) => { + invalidateItemCaches(queryClient, variables.itemId); + }, + }); +} + +export function useReorderItemImages() { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + + return useMutation({ + mutationFn: async ({ itemId, imageIds }: { itemId: string; imageIds: string[] }) => { + applySessionToken(session); + return api.patch(`/items/${itemId}/images/reorder`, { image_ids: imageIds }); + }, + onSuccess: (_, variables) => { + invalidateItemCaches(queryClient, variables.itemId); + }, + }); +} + export function useSetPrimaryImage() { const queryClient = useQueryClient(); const { data: session } = useSession(); @@ -635,6 +717,7 @@ export interface BulkOperationParams { // Filters to apply when using select_all (to match the current view) filters?: { type?: string; + category?: string; search?: string; needs_wash?: boolean; favorite?: boolean; @@ -1038,13 +1121,15 @@ function uploadBulkItemsChunk( files: File[], skipAi: boolean, token: string | null | undefined, - onProgress: (percent: number) => void + onProgress: (percent: number) => void, + removeBackground = false ): Promise { const formData = new FormData(); files.forEach((file) => { formData.append('images', file); }); formData.append('skip_ai', String(skipAi)); + formData.append('remove_background', String(removeBackground)); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); @@ -1115,10 +1200,11 @@ export async function uploadFilesWithinServerLimit( files: File[], skipAi: boolean, token: string | null | undefined, - onProgress: (percent: number) => void + onProgress: (percent: number) => void, + removeBackground = false ): Promise { try { - return await uploadBulkItemsChunk(files, skipAi, token, onProgress); + return await uploadBulkItemsChunk(files, skipAi, token, onProgress, removeBackground); } catch (error) { const limit = error instanceof ApiError && error.status === 400 @@ -1128,7 +1214,13 @@ export async function uploadFilesWithinServerLimit( const responses: BulkUploadResponse[] = []; for (let i = 0; i < files.length; i += limit) { responses.push( - await uploadFilesWithinServerLimit(files.slice(i, i + limit), skipAi, token, onProgress) + await uploadFilesWithinServerLimit( + files.slice(i, i + limit), + skipAi, + token, + onProgress, + removeBackground + ) ); } return mergeBulkUploadResponses(responses); @@ -1243,11 +1335,17 @@ export function useBulkCreateItems() { mutationFn: async ({ files, skipAi = false, + removeBackground = false, }: { files: File[]; skipAi?: boolean; + removeBackground?: boolean; }): Promise => { - const { staged, unprotected: unprotectedFiles } = await enqueueFiles(files, skipAi); + const { staged, unprotected: unprotectedFiles } = await enqueueFiles( + files, + skipAi, + removeBackground + ); if (staged.length > 0) { void startDrain(); } @@ -1263,10 +1361,16 @@ export function useBulkCreateItems() { for (let i = 0; i < chunks.length; i++) { const chunkFiles = chunks[i]; try { - const response = await uploadFilesWithinServerLimit(chunkFiles, skipAi, token, (chunkPercent) => { - const overall = ((i + chunkPercent / 100) / chunks.length) * 100; - setUploadProgress(Math.round(overall)); - }); + const response = await uploadFilesWithinServerLimit( + chunkFiles, + skipAi, + token, + (chunkPercent) => { + const overall = ((i + chunkPercent / 100) / chunks.length) * 100; + setUploadProgress(Math.round(overall)); + }, + removeBackground + ); responses.push(response); } catch (error) { responses.push(failedChunkResponse(chunkFiles, error)); diff --git a/frontend/lib/hooks/use-translated-constants.ts b/frontend/lib/hooks/use-translated-constants.ts index 62f36ff5e..7f3815ae2 100644 --- a/frontend/lib/hooks/use-translated-constants.ts +++ b/frontend/lib/hooks/use-translated-constants.ts @@ -7,6 +7,9 @@ import { CLOTHING_TYPES, CLOTHING_COLORS, FEATURED_OCCASIONS, + ITEM_CATEGORIES, + categoryOfType, + type ItemCategory, } from '@/lib/types'; const STYLE_VALUES = ['bold', 'casual', 'formal', 'minimalist', 'sporty'] as const; @@ -21,6 +24,39 @@ export function useClothingTypes() { })), [t]); } +export interface TypeGroup { + category: ItemCategory; + label: string; + types: { value: string; label: string }[]; +} + +// The type picker split into wardrobe sections (clothing, footwear, accessories), each sorted by +// its translated label. +export function useGroupedClothingTypes(): TypeGroup[] { + const types = useClothingTypes(); + const t = useTranslations('constants.categories'); + const locale = useLocale(); + return useMemo(() => { + const collator = new Intl.Collator(locale); + return ITEM_CATEGORIES.map((category) => ({ + category, + label: t(category), + types: types + .filter((type) => categoryOfType(type.value) === category) + .sort((a, b) => collator.compare(a.label, b.label)), + })); + }, [types, t, locale]); +} + +export function useCategoryLabel() { + return useCatalogLabel(useTranslations('constants.categories')); +} + +export function usePhotoTypeLabel() { + const t = useTranslations('wardrobe.photos'); + return useCallback((type: string) => t(type as 'front'), [t]); +} + export function useClothingColors() { const t = useTranslations('constants.colors'); diff --git a/frontend/lib/remove-background-pref.ts b/frontend/lib/remove-background-pref.ts new file mode 100644 index 000000000..deec2cd21 --- /dev/null +++ b/frontend/lib/remove-background-pref.ts @@ -0,0 +1,19 @@ +// Whether new photos get their background removed, remembered per browser; on by default so new +// photos come out clean. Shared by the upload dialog and the photo manager. +const REMOVE_BACKGROUND_KEY = 'wardrowbe.removeBackground'; + +export function readRemoveBackground(): boolean { + try { + return window.localStorage.getItem(REMOVE_BACKGROUND_KEY) !== 'false'; + } catch { + return true; + } +} + +export function storeRemoveBackground(value: boolean): void { + try { + window.localStorage.setItem(REMOVE_BACKGROUND_KEY, String(value)); + } catch { + // Private mode or blocked storage: the choice just isn't remembered. + } +} diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 094819dfb..eba9600f1 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -1,4 +1,10 @@ -import { CLOTHING_TYPE_VALUES, OCCASION_VALUES } from '@/lib/generated/garment-vocabulary'; +import { + CATEGORY_VALUES, + CLOTHING_TYPE_VALUES, + ITEM_ROLE, + OCCASION_VALUES, + ROLE_CATEGORY, +} from '@/lib/generated/garment-vocabulary'; // API response types matching backend schemas @@ -61,6 +67,9 @@ export interface Item { needs_wash: boolean; effective_wash_interval: number; additional_images: ItemImage[]; + // What the cover photo shows; null when the user has not said. + image_view?: PhotoType | null; + category?: ItemCategory; is_archived: boolean; archived_at?: string; archive_reason?: string; @@ -122,6 +131,7 @@ export interface TaggingProgress { export interface ItemFilter { type?: string; + category?: ItemCategory; subtype?: string; colors?: string[]; status?: string; @@ -174,6 +184,19 @@ export const CLOTHING_TYPES = [...CLOTHING_TYPE_VALUES].sort().map((value) => ({ export type ClothingTypeValue = (typeof CLOTHING_TYPE_VALUES)[number]; +// Wardrobe sections. Each type belongs to one through its role; an unknown type reads as clothing. +export type ItemCategory = (typeof CATEGORY_VALUES)[number]; +export const ITEM_CATEGORIES: readonly ItemCategory[] = CATEGORY_VALUES; + +export function categoryOfType(type: string | null | undefined): ItemCategory { + const role = type ? ITEM_ROLE[type] : undefined; + return ((role && ROLE_CATEGORY[role]) || 'clothing') as ItemCategory; +} + +// What a photo of an item shows. Only one photo can be the front and one the back. +export type PhotoType = 'front' | 'back' | 'detail' | 'other'; +export const PHOTO_TYPES: readonly PhotoType[] = ['front', 'back', 'detail', 'other']; + // Suggested subtypes per type. Mirrors the SUBTYPE examples in clothing_analysis.txt. // Subtype is free text on the backend (and the model may answer outside this list), // so these are suggestions, not a closed set. @@ -249,6 +272,10 @@ export interface ItemImage { thumbnail_path?: string; medium_path?: string; position: number; + view?: PhotoType | null; + // Background removal runs per photo, so each one has its own state and backup. + status?: 'ready' | 'processing' | 'error'; + original_image_path?: string | null; created_at: string; image_url: string; thumbnail_url?: string; diff --git a/frontend/lib/upload-manager.ts b/frontend/lib/upload-manager.ts index acd9ee1a0..42a456130 100644 --- a/frontend/lib/upload-manager.ts +++ b/frontend/lib/upload-manager.ts @@ -149,6 +149,7 @@ async function uploadChunk(chunk: QueuedUpload[]): Promise { const formData = new FormData(); chunk.forEach((record) => formData.append('images', record.file, record.filename)); formData.append('skip_ai', String(chunk[0]?.skipAi ?? false)); + formData.append('remove_background', String(chunk[0]?.removeBackground ?? false)); chunk.forEach((record) => formData.append('upload_keys', record.id)); const token = getAccessToken(); @@ -215,10 +216,13 @@ async function drainOnce(): Promise { }); if (actionable.length === 0) return false; - // A chunk request carries one skip_ai value - keep chunks homogeneous + // A chunk request carries one skip_ai and remove_background value - keep chunks homogeneous // rather than threading a per-file flag through the endpoint. const skipAi = actionable[0].skipAi; - const matching = actionable.filter((r) => r.skipAi === skipAi); + const removeBackground = actionable[0].removeBackground ?? false; + const matching = actionable.filter( + (r) => r.skipAi === skipAi && (r.removeBackground ?? false) === removeBackground + ); const serverLimit = queryClient ? await fetchBulkUploadLimit(queryClient) : null; const maxFiles = Math.min(effectiveChunkSize, serverLimit ?? effectiveChunkSize); const chunk: QueuedUpload[] = []; diff --git a/frontend/lib/upload-queue.ts b/frontend/lib/upload-queue.ts index 0d601d17b..98bda025b 100644 --- a/frontend/lib/upload-queue.ts +++ b/frontend/lib/upload-queue.ts @@ -15,6 +15,8 @@ export interface QueuedUpload { size: number; lastModified: number; skipAi: boolean; + // Absent on records queued before the option existed, which means no. + removeBackground?: boolean; addedAt: number; updatedAt: number; status: UploadStatus; @@ -161,7 +163,8 @@ async function findReusableRecord( export async function enqueueFiles( files: File[], - skipAi: boolean + skipAi: boolean, + removeBackground = false ): Promise<{ staged: string[]; unprotected: File[] }> { if (!hasIndexedDb()) { return { staged: [], unprotected: files }; @@ -191,6 +194,7 @@ export async function enqueueFiles( size: file.size, lastModified: file.lastModified, skipAi, + removeBackground, addedAt: now, updatedAt: now, status: 'pending', diff --git a/frontend/messages/de/analytics.json b/frontend/messages/de/analytics.json index b743090f5..b08358320 100644 --- a/frontend/messages/de/analytics.json +++ b/frontend/messages/de/analytics.json @@ -65,5 +65,9 @@ }, "subtitle": "Deine Kleiderschrank-Einblicke und Statistiken", "title": "Statistiken", - "wearCount": "{count}x" + "wearCount": "{count}x", + "categories": { + "title": "Bereiche des Kleiderschranks", + "description": "Wie sich deine Teile auf Kleidung, Schuhe und Accessoires verteilen." + } } diff --git a/frontend/messages/de/constants.json b/frontend/messages/de/constants.json index d309d9c41..87b4152bd 100644 --- a/frontend/messages/de/constants.json +++ b/frontend/messages/de/constants.json @@ -242,7 +242,12 @@ "tie": "Krawatte", "tights": "Strumpfhose", "top": "Top", - "vest": "Weste" + "vest": "Weste", + "ring": "Ring", + "necklace": "Halskette", + "watch": "Uhr", + "bracelet": "Armband", + "earrings": "Ohrringe" }, "weatherConditions": { "clear": "Klar", @@ -271,5 +276,10 @@ "thunderstorm": "Gewitter", "thunderstorm-with-hail": "Gewitter mit Hagel", "unknown": "Unbekannt" + }, + "categories": { + "clothing": "Kleidung", + "footwear": "Schuhe", + "accessories": "Accessoires" } } diff --git a/frontend/messages/de/wardrobe.json b/frontend/messages/de/wardrobe.json index 37a0ea3fe..3d68a1891 100644 --- a/frontend/messages/de/wardrobe.json +++ b/frontend/messages/de/wardrobe.json @@ -45,7 +45,9 @@ "subtitle": "Lade Fotos deiner Kleidungsstücke hoch", "title": "Stücke hinzufügen", "typeLabel": "Typ (die KI erkennt ihn, wenn leer)", - "uploading": "Wird hochgeladen..." + "uploading": "Wird hochgeladen...", + "removeBackground": "Hintergrund automatisch entfernen", + "removeBackgroundHint": "Das Foto wird bereinigt, bevor die KI es katalogisiert." }, "ai": { "analysisFailed": "Analyse fehlgeschlagen", @@ -277,5 +279,46 @@ "daysAgo": "Vor {days} T. getragen", "today": "Heute getragen", "yesterday": "Gestern getragen" + }, + "photos": { + "title": "Fotos", + "add": "Fotos hinzufügen", + "pickerHint": "Vorderseite, Rückseite und Details: bis zu {count} Fotos. Das erste ist das Titelbild.", + "limit": "Bis zu {count} Fotos pro Teil.", + "invalid": "Einige Dateien sind keine unterstützten Bilder oder größer als 20 MB.", + "type": "Was das Foto zeigt", + "noView": "Nicht angegeben", + "front": "Vorderseite", + "back": "Rückseite", + "detail": "Detail", + "other": "Sonstiges", + "cover": "Titelbild", + "coverHint": "Titelbild", + "makeCover": "Als Titelbild verwenden", + "previous": "Zurück", + "next": "Weiter", + "remove": "Foto löschen", + "removeTitle": "Dieses Foto löschen?", + "removeDescription": "Das Foto wird endgültig gelöscht. Das Teil und seine anderen Fotos bleiben.", + "keep": "Behalten", + "lastPhoto": "Ein Teil braucht mindestens ein Foto. Ersetze es stattdessen.", + "uploading": "{count, plural, one {# Foto wird hochgeladen…} other {# Fotos werden hochgeladen…}}", + "count": "{count} von {max}", + "countShort": "{count, plural, one {# Foto} other {# Fotos}}", + "counter": "Foto {current} von {total}", + "frontBack": "Vorder- und Rückseite", + "showFront": "Vorderseite zeigen", + "showBack": "Rückseite zeigen", + "showCover": "Titelbild zeigen", + "someFailed": "{count, plural, one {Das Teil wurde gespeichert, aber # Foto konnte nicht hochgeladen werden.} other {Das Teil wurde gespeichert, aber # Fotos konnten nicht hochgeladen werden.}}", + "error": "Das Foto konnte nicht gespeichert werden. Versuch es noch einmal.", + "removingBackground": "Hintergrund wird entfernt…", + "backgroundFailed": "Hintergrundentfernung fehlgeschlagen", + "removeBackground": "Hintergrund entfernen", + "restoreBackground": "Original wiederherstellen" + }, + "categories": { + "label": "Bereiche des Kleiderschranks", + "all": "Alles" } } diff --git a/frontend/messages/en/analytics.json b/frontend/messages/en/analytics.json index 28fb4d234..1e96cd5d6 100644 --- a/frontend/messages/en/analytics.json +++ b/frontend/messages/en/analytics.json @@ -65,5 +65,9 @@ }, "subtitle": "Your wardrobe insights and statistics", "title": "Analytics", - "wearCount": "{count}x" + "wearCount": "{count}x", + "categories": { + "title": "Wardrobe sections", + "description": "How your items split between clothing, footwear and accessories." + } } diff --git a/frontend/messages/en/constants.json b/frontend/messages/en/constants.json index 6a0c7d8e0..0848d398f 100644 --- a/frontend/messages/en/constants.json +++ b/frontend/messages/en/constants.json @@ -242,7 +242,12 @@ "tie": "Tie", "tights": "Tights", "top": "Top", - "vest": "Vest" + "vest": "Vest", + "ring": "Ring", + "necklace": "Necklace", + "watch": "Watch", + "bracelet": "Bracelet", + "earrings": "Earrings" }, "weatherConditions": { "clear": "Clear", @@ -271,5 +276,10 @@ "thunderstorm": "Thunderstorm", "thunderstorm-with-hail": "Thunderstorm with Hail", "unknown": "Unknown" + }, + "categories": { + "clothing": "Clothing", + "footwear": "Footwear", + "accessories": "Accessories" } } diff --git a/frontend/messages/en/wardrobe.json b/frontend/messages/en/wardrobe.json index 7794b71a5..aa73aa083 100644 --- a/frontend/messages/en/wardrobe.json +++ b/frontend/messages/en/wardrobe.json @@ -45,7 +45,9 @@ "subtitle": "Upload photos of your clothing items", "title": "Add Items", "typeLabel": "Type (AI will detect if empty)", - "uploading": "Uploading..." + "uploading": "Uploading...", + "removeBackground": "Remove the background automatically", + "removeBackgroundHint": "The photo is cleaned up before the AI catalogues it." }, "ai": { "analysisFailed": "Analysis Failed", @@ -104,7 +106,7 @@ "reanalyzeQueued": "{count, plural, one {Queued # item for re-analysis} other {Queued # items for re-analysis}}", "reanalyzeSkipped": "{count, plural, one {# item already analyzing} other {# items already analyzing}}", "removeBackgroundAlreadyDone": "{count, plural, one {# item already had its background removed} other {# items already had their background removed}}", - "removeBackgroundConfirmDescription": "This will remove the background from {count, plural, one {# selected item} other {# selected items}}. The original photo is kept and can be restored per item afterward.", + "removeBackgroundConfirmDescription": "This will remove the background from {count, plural, one {# selected item} other {# selected items}}, every photo of each. The original photos are kept and can be restored afterward.", "removeBackgroundConfirmTitle": "Remove background from {count, plural, one {# item} other {# items}}?", "removeBackgroundError": "Failed to queue background removal", "removeBackgroundPartialFailed": "{count, plural, one {Failed to queue # item} other {Failed to queue # items}}", @@ -277,5 +279,46 @@ "daysAgo": "Worn {days}d ago", "today": "Worn today", "yesterday": "Worn yesterday" + }, + "photos": { + "title": "Photos", + "add": "Add photos", + "pickerHint": "Front, back and details: up to {count} photos. The first one is the cover.", + "limit": "Up to {count} photos per item.", + "invalid": "Some files are not supported images or are larger than 20 MB.", + "type": "What the photo shows", + "noView": "Not set", + "front": "Front", + "back": "Back", + "detail": "Detail", + "other": "Other", + "cover": "Cover photo", + "coverHint": "Cover", + "makeCover": "Use as cover", + "previous": "Previous", + "next": "Next", + "remove": "Remove photo", + "removeTitle": "Remove this photo?", + "removeDescription": "The photo is deleted for good. The item and its other photos stay.", + "keep": "Keep it", + "lastPhoto": "An item needs at least one photo. Replace it instead.", + "uploading": "{count, plural, one {Uploading # photo…} other {Uploading # photos…}}", + "count": "{count} of {max}", + "countShort": "{count, plural, one {# photo} other {# photos}}", + "counter": "Photo {current} of {total}", + "frontBack": "Front and back", + "showFront": "Show the front", + "showBack": "Show the back", + "showCover": "Show the cover", + "someFailed": "{count, plural, one {The item was saved, but # photo could not be uploaded.} other {The item was saved, but # photos could not be uploaded.}}", + "error": "The photo could not be saved. Try again.", + "removingBackground": "Removing background…", + "backgroundFailed": "Background removal failed", + "removeBackground": "Remove background", + "restoreBackground": "Restore original" + }, + "categories": { + "label": "Wardrobe sections", + "all": "All" } } diff --git a/frontend/messages/fr/analytics.json b/frontend/messages/fr/analytics.json index d4cd9c72e..c88ff6f37 100644 --- a/frontend/messages/fr/analytics.json +++ b/frontend/messages/fr/analytics.json @@ -65,5 +65,9 @@ }, "subtitle": "Vos aperçus et statistiques de garde-robe", "title": "Statistiques", - "wearCount": "{count}x" + "wearCount": "{count}x", + "categories": { + "title": "Sections de la garde-robe", + "description": "La répartition de tes articles entre vêtements, chaussures et accessoires." + } } diff --git a/frontend/messages/fr/constants.json b/frontend/messages/fr/constants.json index 4b5ecafe1..bfa904fed 100644 --- a/frontend/messages/fr/constants.json +++ b/frontend/messages/fr/constants.json @@ -242,7 +242,12 @@ "tie": "Cravate", "tights": "Collants", "top": "Haut", - "vest": "Gilet sans manches" + "vest": "Gilet sans manches", + "ring": "Bague", + "necklace": "Collier", + "watch": "Montre", + "bracelet": "Bracelet", + "earrings": "Boucles d'oreilles" }, "weatherConditions": { "clear": "Dégagé", @@ -271,5 +276,10 @@ "thunderstorm": "Orage", "thunderstorm-with-hail": "Orage avec grêle", "unknown": "Inconnu" + }, + "categories": { + "clothing": "Vêtements", + "footwear": "Chaussures", + "accessories": "Accessoires" } } diff --git a/frontend/messages/fr/wardrobe.json b/frontend/messages/fr/wardrobe.json index f9c91c0ff..0d21f0689 100644 --- a/frontend/messages/fr/wardrobe.json +++ b/frontend/messages/fr/wardrobe.json @@ -45,7 +45,9 @@ "subtitle": "Importez des photos de vos vêtements", "title": "Ajouter des articles", "typeLabel": "Type (l'IA détectera si vide)", - "uploading": "Import en cours..." + "uploading": "Import en cours...", + "removeBackground": "Supprimer l'arrière-plan automatiquement", + "removeBackgroundHint": "La photo est détourée avant que l'IA ne la catalogue." }, "ai": { "analysisFailed": "Analyse échouée", @@ -277,5 +279,46 @@ "daysAgo": "Porté il y a {days} j", "today": "Porté aujourd'hui", "yesterday": "Porté hier" + }, + "photos": { + "title": "Photos", + "add": "Ajouter des photos", + "pickerHint": "Devant, dos et détails : jusqu'à {count} photos. La première est la couverture.", + "limit": "Jusqu'à {count} photos par vêtement.", + "invalid": "Certains fichiers ne sont pas des images prises en charge ou dépassent 20 Mo.", + "type": "Ce que montre la photo", + "noView": "Non précisé", + "front": "Devant", + "back": "Dos", + "detail": "Détail", + "other": "Autre", + "cover": "Photo de couverture", + "coverHint": "Couverture", + "makeCover": "Utiliser comme couverture", + "previous": "Précédente", + "next": "Suivante", + "remove": "Supprimer la photo", + "removeTitle": "Supprimer cette photo ?", + "removeDescription": "La photo est supprimée définitivement. Le vêtement et ses autres photos restent.", + "keep": "La garder", + "lastPhoto": "Un vêtement doit avoir au moins une photo. Remplace-la plutôt.", + "uploading": "{count, plural, one {Envoi de # photo…} other {Envoi de # photos…}}", + "count": "{count} sur {max}", + "countShort": "{count, plural, one {# photo} other {# photos}}", + "counter": "Photo {current} sur {total}", + "frontBack": "Devant et dos", + "showFront": "Voir le devant", + "showBack": "Voir le dos", + "showCover": "Voir la couverture", + "someFailed": "{count, plural, one {Le vêtement est enregistré, mais # photo n'a pas pu être envoyée.} other {Le vêtement est enregistré, mais # photos n'ont pas pu être envoyées.}}", + "error": "Impossible d'enregistrer la photo. Réessaie.", + "removingBackground": "Suppression du fond…", + "backgroundFailed": "Échec de la suppression du fond", + "removeBackground": "Supprimer le fond", + "restoreBackground": "Restaurer l'original" + }, + "categories": { + "label": "Sections de la garde-robe", + "all": "Tout" } } diff --git a/frontend/messages/it/analytics.json b/frontend/messages/it/analytics.json index de5a09fb0..708d75cf4 100644 --- a/frontend/messages/it/analytics.json +++ b/frontend/messages/it/analytics.json @@ -65,5 +65,9 @@ }, "subtitle": "Approfondimenti e statistiche del tuo guardaroba", "title": "Analisi", - "wearCount": "{count}x" + "wearCount": "{count}x", + "categories": { + "title": "Sezioni del guardaroba", + "description": "Come si dividono i tuoi capi tra abbigliamento, scarpe e accessori." + } } diff --git a/frontend/messages/it/constants.json b/frontend/messages/it/constants.json index bf5fcd493..fddab8751 100644 --- a/frontend/messages/it/constants.json +++ b/frontend/messages/it/constants.json @@ -242,7 +242,12 @@ "tie": "Cravatta", "tights": "Collant", "top": "Top", - "vest": "Gilet" + "vest": "Gilet", + "ring": "Anello", + "necklace": "Collana", + "watch": "Orologio", + "bracelet": "Braccialetto", + "earrings": "Orecchini" }, "weatherConditions": { "clear": "Sereno", @@ -271,5 +276,10 @@ "thunderstorm": "Temporale", "thunderstorm-with-hail": "Temporale con grandine", "unknown": "Sconosciuto" + }, + "categories": { + "clothing": "Abbigliamento", + "footwear": "Scarpe", + "accessories": "Accessori" } } diff --git a/frontend/messages/it/wardrobe.json b/frontend/messages/it/wardrobe.json index 278489d4f..0bed3d455 100644 --- a/frontend/messages/it/wardrobe.json +++ b/frontend/messages/it/wardrobe.json @@ -19,7 +19,7 @@ "hint": "Tutti i capi saranno etichettati automaticamente dall'IA. Potrai modificare i dettagli in seguito.", "imageCount": "{count, plural, one {# immagine selezionata} other {# immagini selezionate}}", "partial": "{success} caricati, {failed} falliti", - "queued": "{count, plural, one {# photo queued for upload} other {# photos queued for upload}}", + "queued": "{count, plural, one {# foto in coda per il caricamento} other {# foto in coda per il caricamento}}", "resultFailed": "{count, plural, one {# capo fallito} other {# capi falliti}}", "resultSuccess": "{success} di {total} caricati con successo", "skipAi": "Salta l’analisi IA (aggiungi con valori segnaposto, modifica i dettagli in seguito)", @@ -45,75 +45,77 @@ "subtitle": "Carica foto dei tuoi capi di abbigliamento", "title": "Aggiungi capi", "typeLabel": "Tipo (l'IA rileverà se vuoto)", - "uploading": "Caricamento in corso..." + "uploading": "Caricamento in corso...", + "removeBackground": "Rimuovi lo sfondo automaticamente", + "removeBackgroundHint": "La foto viene ripulita prima che l'IA la catalogui." }, "ai": { "analysisFailed": "Analisi fallita", "analyzing": "Analisi IA in corso...", "analyzingCount": "{count} in analisi", - "analyzingElapsed": "Analyzing… {elapsed}", + "analyzingElapsed": "Analisi in corso… {elapsed}", "analyzingProgress": "{count} in analisi · {percent}% completato", - "backgroundRemovalFailed": "Background removal failed", - "cancelAll": "Cancel all", + "backgroundRemovalFailed": "Rimozione sfondo non riuscita", + "cancelAll": "Annulla tutto", "completeness": "Completezza IA: {percent}%", "confident": "{percent}% fiducia", "dismiss": "Ignora", "failedCount": "{count} falliti", "queue": { - "title": "AI analysis queue", - "description": "What the analyzer is working on right now, and how the current batch is going.", - "idle": "Nothing is waiting for analysis.", - "batchProgress": "{done} of {total} analyzed", - "percentDone": "{percent}% done", - "batchFailed": "{count, plural, one {# item failed} other {# items failed}}", - "nowAnalyzing": "Analyzing now", - "waitingToStart": "Waiting for the worker to pick up the next item.", - "averageLabel": "Average per item", - "remainingLabel": "Estimated time left", - "noEstimateYet": "Not enough data yet", - "recentlyFinished": "Recently finished", - "durationUnknown": "Duration unknown", - "failedHeading": "Failed", - "unknownError": "No reason was reported.", - "unnamedItem": "Untitled item", - "concurrency": "{count, plural, one {This instance analyzes # item at a time.} other {This instance analyzes # items at a time.}}", - "concurrencyLink": "Change this", - "open": "View queue" + "title": "Coda di analisi IA", + "description": "Cosa sta analizzando l'IA in questo momento e come procede il gruppo attuale.", + "idle": "Niente in attesa di analisi.", + "batchProgress": "{done} di {total} analizzati", + "percentDone": "{percent}% completato", + "batchFailed": "{count, plural, one {# capo non riuscito} other {# capi non riusciti}}", + "nowAnalyzing": "In analisi ora", + "waitingToStart": "In attesa che il worker prenda il prossimo capo.", + "averageLabel": "Media per capo", + "remainingLabel": "Tempo stimato rimanente", + "noEstimateYet": "Dati ancora insufficienti", + "recentlyFinished": "Finiti di recente", + "durationUnknown": "Durata sconosciuta", + "failedHeading": "Non riusciti", + "unknownError": "Nessun motivo indicato.", + "unnamedItem": "Capo senza nome", + "concurrency": "{count, plural, one {Questa istanza analizza # capo alla volta.} other {Questa istanza analizza # capi alla volta.}}", + "concurrencyLink": "Modifica", + "open": "Vedi coda" }, - "queued": "Queued", - "queuedCount": "{count} queued", - "removeBackgroundQueued": "Queued for background removal", - "removingBackground": "Removing background...", - "removingBackgroundElapsed": "Removing background… {elapsed}", - "retryCooldown": "{seconds, plural, one {Retry available in # second} other {Retry available in # seconds}}", - "rotateFailed": "Rotation failed", - "rotateQueued": "Queued for rotation", - "rotating": "Rotating…" + "queued": "In coda", + "queuedCount": "{count} in coda", + "removeBackgroundQueued": "In coda per la rimozione sfondo", + "removingBackground": "Rimozione sfondo…", + "removingBackgroundElapsed": "Rimozione sfondo… {elapsed}", + "retryCooldown": "{seconds, plural, one {Nuovo tentativo tra # secondo} other {Nuovo tentativo tra # secondi}}", + "rotateFailed": "Rotazione non riuscita", + "rotateQueued": "In coda per la rotazione", + "rotating": "Rotazione…" }, "allTypes": "Tutti i tipi", "bulkActions": { - "cancelAllError": "Failed to cancel analysis", - "cancelAllQueued": "{count, plural, one {Cancelled # in-progress analysis} other {Cancelled # in-progress analyses}}", + "cancelAllError": "Impossibile annullare l'analisi", + "cancelAllQueued": "{count, plural, one {Annullata # analisi in corso} other {Annullate # analisi in corso}}", "deleteError": "Eliminazione capi fallita", "deletePartialFailed": "{count, plural, one {Impossibile eliminare # capo} other {Impossibile eliminare # capi}}", "deleteSuccess": "{count, plural, one {# capo eliminato} other {# capi eliminati}}", - "reanalyzeCooldown": "{count, plural, one {# item cooling down after a recent failure} other {# items cooling down after a recent failure}}", + "reanalyzeCooldown": "{count, plural, one {# capo in pausa dopo un errore recente} other {# capi in pausa dopo un errore recente}}", "reanalyzeError": "Impossibile mettere in coda i capi per la rianalisi", "reanalyzeMany": "{count} capi messi in coda per la rianalisi. Potrebbe volerci un po’.", "reanalyzePartialFailed": "{count, plural, one {Impossibile mettere in coda # capo} other {Impossibile mettere in coda # capi}}", "reanalyzeQueued": "{count, plural, one {# capo messo in coda per la rianalisi} other {# capi messi in coda per la rianalisi}}", - "reanalyzeSkipped": "{count, plural, one {# item already analyzing} other {# items already analyzing}}", - "removeBackgroundAlreadyDone": "{count, plural, one {# item already had its background removed} other {# items already had their background removed}}", - "removeBackgroundConfirmDescription": "This will remove the background from {count, plural, one {# selected item} other {# selected items}}. The original photo is kept and can be restored per item afterward.", - "removeBackgroundConfirmTitle": "Remove background from {count, plural, one {# item} other {# items}}?", - "removeBackgroundError": "Failed to queue background removal", - "removeBackgroundPartialFailed": "{count, plural, one {Failed to queue # item} other {Failed to queue # items}}", - "removeBackgroundQueued": "{count, plural, one {Queued # item for background removal} other {Queued # items for background removal}}", - "removeBackgroundSkipped": "{count, plural, one {# item already processing} other {# items already processing}}", + "reanalyzeSkipped": "{count, plural, one {# capo già in analisi} other {# capi già in analisi}}", + "removeBackgroundAlreadyDone": "{count, plural, one {# capo aveva già lo sfondo rimosso} other {# capi avevano già lo sfondo rimosso}}", + "removeBackgroundConfirmDescription": "Verrà rimosso lo sfondo da {count, plural, one {# capo selezionato} other {# capi selezionati}}, da tutte le loro foto. La foto originale viene conservata e si può ripristinare.", + "removeBackgroundConfirmTitle": "Rimuovere lo sfondo da {count, plural, one {# capo} other {# capi}}?", + "removeBackgroundError": "Impossibile mettere in coda la rimozione sfondo", + "removeBackgroundPartialFailed": "{count, plural, one {Impossibile mettere in coda # capo} other {Impossibile mettere in coda # capi}}", + "removeBackgroundQueued": "{count, plural, one {# capo in coda per la rimozione sfondo} other {# capi in coda per la rimozione sfondo}}", + "removeBackgroundSkipped": "{count, plural, one {# capo già in elaborazione} other {# capi già in elaborazione}}", "rotateError": "Failed to rotate items", "rotatePartialFailed": "{count, plural, one {Failed to rotate # item} other {Failed to rotate # items}}", - "rotateQueued": "{count, plural, one {Queued # item for rotation} other {Queued # items for rotation}}", - "rotateSkipped": "{count, plural, one {# item skipped (already processing)} other {# items skipped (already processing)}}" + "rotateQueued": "{count, plural, one {# capo in coda per la rotazione} other {# capi in coda per la rotazione}}", + "rotateSkipped": "{count, plural, one {# capo saltato (già in elaborazione)} other {# capi saltati (già in elaborazione)}}" }, "bulkDelete": { "confirmDescription": "Questo eliminerà definitivamente i capi selezionati e le relative immagini. Questa azione non può essere annullata.", @@ -253,17 +255,17 @@ }, "title": "Il mio guardaroba", "uploadQueue": { - "remaining": "{count, plural, one {# photo uploading in the background} other {# photos uploading in the background}}", - "keepTabOpen": "Keep this tab open until this finishes", + "remaining": "{count, plural, one {# foto in caricamento in background} other {# foto in caricamento in background}}", + "keepTabOpen": "Tieni aperta questa scheda finché non ha finito", "cancel": "Cancel", - "storageNotPersisted": "Your browser may clear queued photos if you navigate away before this finishes", - "resuming": "Resuming {count, plural, one {# photo} other {# photos}} from an earlier import", - "failedCount": "{count, plural, one {# photo could not be uploaded} other {# photos could not be uploaded}}", - "retryAll": "Retry all", - "dismissAll": "Dismiss all", - "retry": "Retry", - "dismiss": "Dismiss", - "unprotectedWarning": "{count, plural, one {# file could not be protected against interruption (storage full)} other {# files could not be protected against interruption (storage full)}} - keep this tab open until upload completes", + "storageNotPersisted": "Il browser potrebbe cancellare le foto in coda se esci prima della fine", + "resuming": "Ripresa di {count, plural, one {# foto} other {# foto}} da un'importazione precedente", + "failedCount": "{count, plural, one {# foto non caricata} other {# foto non caricate}}", + "retryAll": "Riprova tutto", + "dismissAll": "Ignora tutto", + "retry": "Riprova", + "dismiss": "Ignora", + "unprotectedWarning": "{count, plural, one {# file non protetto da interruzioni (memoria piena)} other {# file non protetti da interruzioni (memoria piena)}} - tieni aperta questa scheda fino alla fine del caricamento", "failure": { "tooLarge": "{name} supera {limit} MB", "tooLargeForServer": "{name} è troppo grande perché il server lo accetti", @@ -277,5 +279,46 @@ "daysAgo": "Indossato {days}g fa", "today": "Indossato oggi", "yesterday": "Indossato ieri" + }, + "photos": { + "title": "Foto", + "add": "Aggiungi foto", + "pickerHint": "Fronte, retro e dettagli: fino a {count} foto. La prima è la copertina.", + "limit": "Fino a {count} foto per capo.", + "invalid": "Alcuni file non sono immagini supportate o superano i 20 MB.", + "type": "Cosa mostra la foto", + "noView": "Non indicato", + "front": "Fronte", + "back": "Retro", + "detail": "Dettaglio", + "other": "Altro", + "cover": "Foto di copertina", + "coverHint": "Copertina", + "makeCover": "Usa come copertina", + "previous": "Precedente", + "next": "Successiva", + "remove": "Elimina foto", + "removeTitle": "Eliminare questa foto?", + "removeDescription": "La foto viene cancellata definitivamente. Il capo e le altre foto restano.", + "keep": "Tienila", + "lastPhoto": "Un capo deve avere almeno una foto. Sostituiscila invece.", + "uploading": "{count, plural, one {Caricamento di # foto…} other {Caricamento di # foto…}}", + "count": "{count} di {max}", + "countShort": "{count, plural, one {# foto} other {# foto}}", + "counter": "Foto {current} di {total}", + "frontBack": "Fronte e retro", + "showFront": "Mostra il fronte", + "showBack": "Mostra il retro", + "showCover": "Mostra la copertina", + "someFailed": "{count, plural, one {Il capo è stato salvato, ma # foto non è stata caricata.} other {Il capo è stato salvato, ma # foto non sono state caricate.}}", + "error": "Impossibile salvare la foto. Riprova.", + "removingBackground": "Rimozione sfondo…", + "backgroundFailed": "Rimozione sfondo non riuscita", + "removeBackground": "Rimuovi sfondo", + "restoreBackground": "Ripristina originale" + }, + "categories": { + "label": "Sezioni del guardaroba", + "all": "Tutto" } } diff --git a/frontend/messages/ja/analytics.json b/frontend/messages/ja/analytics.json index e25c735de..83b54f0ec 100644 --- a/frontend/messages/ja/analytics.json +++ b/frontend/messages/ja/analytics.json @@ -65,5 +65,9 @@ }, "subtitle": "ワードローブのインサイトと統計", "title": "分析", - "wearCount": "{count}回" + "wearCount": "{count}回", + "categories": { + "title": "ワードローブの区分", + "description": "衣類・シューズ・アクセサリーの内訳です。" + } } diff --git a/frontend/messages/ja/constants.json b/frontend/messages/ja/constants.json index 0d92bb893..718b210fe 100644 --- a/frontend/messages/ja/constants.json +++ b/frontend/messages/ja/constants.json @@ -242,7 +242,12 @@ "tie": "ネクタイ", "tights": "タイツ", "top": "トップス", - "vest": "ベスト" + "vest": "ベスト", + "ring": "指輪", + "necklace": "ネックレス", + "watch": "腕時計", + "bracelet": "ブレスレット", + "earrings": "イヤリング" }, "weatherConditions": { "clear": "晴れ", @@ -271,5 +276,10 @@ "thunderstorm": "雷雨", "thunderstorm-with-hail": "雷雨とひょう", "unknown": "不明" + }, + "categories": { + "clothing": "衣類", + "footwear": "シューズ", + "accessories": "アクセサリー" } } diff --git a/frontend/messages/ja/wardrobe.json b/frontend/messages/ja/wardrobe.json index 835c2df16..da9d71704 100644 --- a/frontend/messages/ja/wardrobe.json +++ b/frontend/messages/ja/wardrobe.json @@ -45,7 +45,9 @@ "subtitle": "衣類の写真をアップロード", "title": "アイテムを追加", "typeLabel": "タイプ(空欄ならAIが検出)", - "uploading": "アップロード中..." + "uploading": "アップロード中...", + "removeBackground": "背景を自動で削除", + "removeBackgroundHint": "AIが分類する前に写真の背景を消します。" }, "ai": { "analysisFailed": "分析失敗", @@ -277,5 +279,46 @@ "daysAgo": "{days}日前に着用", "today": "今日着用", "yesterday": "昨日着用" + }, + "photos": { + "title": "写真", + "add": "写真を追加", + "pickerHint": "表・裏・ディテールなど最大{count}枚。1枚目がカバー写真になります。", + "limit": "1アイテムにつき最大{count}枚です。", + "invalid": "対応していない画像、または20MBを超えるファイルがあります。", + "type": "写真の内容", + "noView": "未設定", + "front": "表", + "back": "裏", + "detail": "ディテール", + "other": "その他", + "cover": "カバー写真", + "coverHint": "カバー", + "makeCover": "カバーにする", + "previous": "前へ", + "next": "次へ", + "remove": "写真を削除", + "removeTitle": "この写真を削除しますか?", + "removeDescription": "写真は完全に削除されます。アイテムと他の写真は残ります。", + "keep": "残す", + "lastPhoto": "アイテムには写真が1枚以上必要です。代わりに差し替えてください。", + "uploading": "{count, plural, other {#枚をアップロード中…}}", + "count": "{count} / {max}", + "countShort": "{count, plural, other {#枚}}", + "counter": "{total}枚中{current}枚目", + "frontBack": "表と裏", + "showFront": "表を表示", + "showBack": "裏を表示", + "showCover": "カバーを表示", + "someFailed": "{count, plural, other {アイテムは保存されましたが、#枚の写真をアップロードできませんでした。}}", + "error": "写真を保存できませんでした。もう一度お試しください。", + "removingBackground": "背景を削除中…", + "backgroundFailed": "背景の削除に失敗しました", + "removeBackground": "背景を削除", + "restoreBackground": "元に戻す" + }, + "categories": { + "label": "ワードローブの区分", + "all": "すべて" } } diff --git a/frontend/messages/ko/analytics.json b/frontend/messages/ko/analytics.json index 0ef9226aa..913b71ebd 100644 --- a/frontend/messages/ko/analytics.json +++ b/frontend/messages/ko/analytics.json @@ -65,5 +65,9 @@ }, "subtitle": "옷장 인사이트와 통계", "title": "분석", - "wearCount": "{count}회" + "wearCount": "{count}회", + "categories": { + "title": "옷장 구분", + "description": "의류, 신발, 액세서리별 아이템 비율이에요." + } } diff --git a/frontend/messages/ko/constants.json b/frontend/messages/ko/constants.json index 020a839e4..822db588e 100644 --- a/frontend/messages/ko/constants.json +++ b/frontend/messages/ko/constants.json @@ -242,7 +242,12 @@ "tie": "넥타이", "tights": "타이츠", "top": "상의", - "vest": "조끼" + "vest": "조끼", + "ring": "반지", + "necklace": "목걸이", + "watch": "시계", + "bracelet": "팔찌", + "earrings": "귀걸이" }, "weatherConditions": { "clear": "맑음", @@ -271,5 +276,10 @@ "thunderstorm": "뇌우", "thunderstorm-with-hail": "우박을 동반한 뇌우", "unknown": "알 수 없음" + }, + "categories": { + "clothing": "의류", + "footwear": "신발", + "accessories": "액세서리" } } diff --git a/frontend/messages/ko/wardrobe.json b/frontend/messages/ko/wardrobe.json index 96a0ee3b6..bc2571ea7 100644 --- a/frontend/messages/ko/wardrobe.json +++ b/frontend/messages/ko/wardrobe.json @@ -45,7 +45,9 @@ "subtitle": "옷 사진을 업로드하세요", "title": "아이템 추가", "typeLabel": "유형 (비워두면 AI가 감지해요)", - "uploading": "업로드 중..." + "uploading": "업로드 중...", + "removeBackground": "배경 자동 제거", + "removeBackgroundHint": "AI가 분류하기 전에 사진 배경을 지워요." }, "ai": { "analysisFailed": "분석 실패", @@ -277,5 +279,46 @@ "daysAgo": "{days}일 전 착용", "today": "오늘 착용", "yesterday": "어제 착용" + }, + "photos": { + "title": "사진", + "add": "사진 추가", + "pickerHint": "앞면, 뒷면, 디테일 등 최대 {count}장. 첫 번째 사진이 대표 사진이에요.", + "limit": "아이템당 최대 {count}장까지 가능해요.", + "invalid": "지원하지 않는 이미지이거나 20MB를 넘는 파일이 있어요.", + "type": "사진 내용", + "noView": "지정 안 함", + "front": "앞면", + "back": "뒷면", + "detail": "디테일", + "other": "기타", + "cover": "대표 사진", + "coverHint": "대표", + "makeCover": "대표 사진으로", + "previous": "이전", + "next": "다음", + "remove": "사진 삭제", + "removeTitle": "이 사진을 삭제할까요?", + "removeDescription": "사진이 영구 삭제돼요. 아이템과 다른 사진은 그대로 남아요.", + "keep": "유지", + "lastPhoto": "아이템에는 사진이 한 장 이상 필요해요. 대신 교체하세요.", + "uploading": "{count, plural, other {사진 #장 업로드 중…}}", + "count": "{max}장 중 {count}장", + "countShort": "{count, plural, other {사진 #장}}", + "counter": "{total}장 중 {current}번째", + "frontBack": "앞면과 뒷면", + "showFront": "앞면 보기", + "showBack": "뒷면 보기", + "showCover": "대표 사진 보기", + "someFailed": "{count, plural, other {아이템은 저장했지만 사진 #장을 업로드하지 못했어요.}}", + "error": "사진을 저장하지 못했어요. 다시 시도하세요.", + "removingBackground": "배경 제거 중…", + "backgroundFailed": "배경 제거 실패", + "removeBackground": "배경 제거", + "restoreBackground": "원본 복원" + }, + "categories": { + "label": "옷장 구분", + "all": "전체" } } diff --git a/frontend/messages/zh-CN/analytics.json b/frontend/messages/zh-CN/analytics.json index 6851aae9a..73f506de8 100644 --- a/frontend/messages/zh-CN/analytics.json +++ b/frontend/messages/zh-CN/analytics.json @@ -65,5 +65,9 @@ }, "subtitle": "你的衣柜洞察与统计", "title": "数据分析", - "wearCount": "{count}次" + "wearCount": "{count}次", + "categories": { + "title": "衣橱分区", + "description": "你的衣物在服装、鞋履和配饰之间的分布。" + } } diff --git a/frontend/messages/zh-CN/constants.json b/frontend/messages/zh-CN/constants.json index fd7a87dcd..93049f375 100644 --- a/frontend/messages/zh-CN/constants.json +++ b/frontend/messages/zh-CN/constants.json @@ -242,7 +242,12 @@ "tie": "领带", "tights": "连裤袜", "top": "上装", - "vest": "马甲" + "vest": "马甲", + "ring": "戒指", + "necklace": "项链", + "watch": "手表", + "bracelet": "手链", + "earrings": "耳环" }, "weatherConditions": { "clear": "晴天", @@ -271,5 +276,10 @@ "thunderstorm": "雷暴", "thunderstorm-with-hail": "雷暴伴冰雹", "unknown": "未知" + }, + "categories": { + "clothing": "服装", + "footwear": "鞋履", + "accessories": "配饰" } } diff --git a/frontend/messages/zh-CN/wardrobe.json b/frontend/messages/zh-CN/wardrobe.json index 58a43acf5..13d417a4b 100644 --- a/frontend/messages/zh-CN/wardrobe.json +++ b/frontend/messages/zh-CN/wardrobe.json @@ -45,7 +45,9 @@ "subtitle": "上传你的衣物照片", "title": "添加单品", "typeLabel": "类型(留空则由 AI 检测)", - "uploading": "上传中..." + "uploading": "上传中...", + "removeBackground": "自动去除背景", + "removeBackgroundHint": "在 AI 分类之前先去掉照片背景。" }, "ai": { "analysisFailed": "分析失败", @@ -277,5 +279,46 @@ "daysAgo": "{days}天前穿过", "today": "今天穿过", "yesterday": "昨天穿过" + }, + "photos": { + "title": "照片", + "add": "添加照片", + "pickerHint": "正面、背面和细节:最多 {count} 张。第一张为封面。", + "limit": "每件衣物最多 {count} 张照片。", + "invalid": "部分文件不是支持的图片或超过 20 MB。", + "type": "照片内容", + "noView": "未设置", + "front": "正面", + "back": "背面", + "detail": "细节", + "other": "其他", + "cover": "封面照片", + "coverHint": "封面", + "makeCover": "设为封面", + "previous": "上一张", + "next": "下一张", + "remove": "删除照片", + "removeTitle": "删除这张照片?", + "removeDescription": "照片将被永久删除,衣物和其他照片会保留。", + "keep": "保留", + "lastPhoto": "衣物至少需要一张照片,请改为替换。", + "uploading": "{count, plural, other {正在上传 # 张照片…}}", + "count": "{count} / {max}", + "countShort": "{count, plural, other {# 张照片}}", + "counter": "第 {current} 张,共 {total} 张", + "frontBack": "正面和背面", + "showFront": "显示正面", + "showBack": "显示背面", + "showCover": "显示封面", + "someFailed": "{count, plural, other {衣物已保存,但有 # 张照片未能上传。}}", + "error": "无法保存照片,请重试。", + "removingBackground": "正在去除背景…", + "backgroundFailed": "去除背景失败", + "removeBackground": "去除背景", + "restoreBackground": "恢复原图" + }, + "categories": { + "label": "衣橱分区", + "all": "全部" } } diff --git a/frontend/messages/zh-TW/analytics.json b/frontend/messages/zh-TW/analytics.json index 2bdc989b5..8d150b955 100644 --- a/frontend/messages/zh-TW/analytics.json +++ b/frontend/messages/zh-TW/analytics.json @@ -65,5 +65,9 @@ }, "subtitle": "你的衣櫥洞察與統計", "title": "數據分析", - "wearCount": "{count} 次" + "wearCount": "{count} 次", + "categories": { + "title": "衣櫥分區", + "description": "你的衣物在服裝、鞋履和配飾之間的分布。" + } } diff --git a/frontend/messages/zh-TW/constants.json b/frontend/messages/zh-TW/constants.json index 0687caaf8..3688225a1 100644 --- a/frontend/messages/zh-TW/constants.json +++ b/frontend/messages/zh-TW/constants.json @@ -242,7 +242,12 @@ "tie": "領帶", "tights": "連褲襪", "top": "上衣", - "vest": "馬甲" + "vest": "馬甲", + "ring": "戒指", + "necklace": "項鍊", + "watch": "手錶", + "bracelet": "手鍊", + "earrings": "耳環" }, "weatherConditions": { "clear": "晴朗", @@ -271,5 +276,10 @@ "thunderstorm": "雷雨", "thunderstorm-with-hail": "雷雨夾冰雹", "unknown": "未知" + }, + "categories": { + "clothing": "服裝", + "footwear": "鞋履", + "accessories": "配飾" } } diff --git a/frontend/messages/zh-TW/wardrobe.json b/frontend/messages/zh-TW/wardrobe.json index 4892c17d9..ab831949f 100644 --- a/frontend/messages/zh-TW/wardrobe.json +++ b/frontend/messages/zh-TW/wardrobe.json @@ -45,7 +45,9 @@ "subtitle": "上傳你的衣物照片", "title": "新增單品", "typeLabel": "類型(留空則由 AI 偵測)", - "uploading": "上傳中..." + "uploading": "上傳中...", + "removeBackground": "自動去除背景", + "removeBackgroundHint": "在 AI 分類之前先去掉照片背景。" }, "ai": { "analysisFailed": "分析失敗", @@ -277,5 +279,46 @@ "daysAgo": "{days} 天前穿過", "today": "今天穿過", "yesterday": "昨天穿過" + }, + "photos": { + "title": "照片", + "add": "新增照片", + "pickerHint": "正面、背面和細節:最多 {count} 張。第一張為封面。", + "limit": "每件衣物最多 {count} 張照片。", + "invalid": "部分檔案不是支援的圖片或超過 20 MB。", + "type": "照片內容", + "noView": "未設定", + "front": "正面", + "back": "背面", + "detail": "細節", + "other": "其他", + "cover": "封面照片", + "coverHint": "封面", + "makeCover": "設為封面", + "previous": "上一張", + "next": "下一張", + "remove": "刪除照片", + "removeTitle": "刪除這張照片?", + "removeDescription": "照片將被永久刪除,衣物和其他照片會保留。", + "keep": "保留", + "lastPhoto": "衣物至少需要一張照片,請改為替換。", + "uploading": "{count, plural, other {正在上傳 # 張照片…}}", + "count": "{count} / {max}", + "countShort": "{count, plural, other {# 張照片}}", + "counter": "第 {current} 張,共 {total} 張", + "frontBack": "正面和背面", + "showFront": "顯示正面", + "showBack": "顯示背面", + "showCover": "顯示封面", + "someFailed": "{count, plural, other {衣物已儲存,但有 # 張照片未能上傳。}}", + "error": "無法儲存照片,請再試一次。", + "removingBackground": "正在去除背景…", + "backgroundFailed": "去除背景失敗", + "removeBackground": "去除背景", + "restoreBackground": "還原原圖" + }, + "categories": { + "label": "衣櫥分區", + "all": "全部" } } diff --git a/frontend/scripts/gen-shared-data.mjs b/frontend/scripts/gen-shared-data.mjs index 5ddbb8128..dc5bf46f1 100644 --- a/frontend/scripts/gen-shared-data.mjs +++ b/frontend/scripts/gen-shared-data.mjs @@ -20,9 +20,12 @@ const header = (source) => [ '// Do not edit by hand; run `npm run vocab:gen`.', ]; -function renderVocabulary({ roles: roleOrder, types, materials, formality, occasions, colors, color_aliases: colorAliases }) { +function renderVocabulary({ roles: roleOrder, types, materials, formality, occasions, colors, color_aliases: colorAliases, categories }) { const roles = types.map(({ value, role }) => ` ${key(value)}: ${quote(role)},`).join('\n'); const swatches = colors.map(({ value, hex }) => ` { value: ${quote(value)}, hex: ${quote(hex)} },`).join('\n'); + const roleCategories = Object.entries(categories) + .flatMap(([category, roles]) => roles.map((role) => ` ${key(role)}: ${quote(category)},`)) + .join('\n'); const aliases = Object.entries(colorAliases) .map(([alias, color]) => ` ${key(alias)}: ${quote(color)},`) .join('\n'); @@ -32,6 +35,12 @@ function renderVocabulary({ roles: roleOrder, types, materials, formality, occas `export const FORMALITY_VALUES = ${list(formality)};`, `export const OCCASION_VALUES = ${list(occasions.map((o) => o.value))};`, `export const ROLE_VALUES = ${list(roleOrder)};`, + `export const CATEGORY_VALUES = ${list(Object.keys(categories))};`, + '', + '// Wardrobe section (clothing, footwear, accessories) of each role.', + 'export const ROLE_CATEGORY: Record = {', + roleCategories, + '};', '', 'export const ITEM_ROLE: Record = {', roles, diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index f2fcd5f85..6a1a0ca2a 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -83,6 +83,14 @@ module.exports = { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))', }, + category: { + clothing: 'hsl(var(--category-clothing))', + footwear: 'hsl(var(--category-footwear))', + accessories: 'hsl(var(--category-accessories))', + }, + success: 'hsl(var(--success))', + warning: 'hsl(var(--warning))', + info: 'hsl(var(--info))', }, borderRadius: { lg: 'var(--radius)', diff --git a/frontend/tests/photo-picker.test.tsx b/frontend/tests/photo-picker.test.tsx new file mode 100644 index 000000000..eee373bfd --- /dev/null +++ b/frontend/tests/photo-picker.test.tsx @@ -0,0 +1,110 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { PhotoPicker, type DraftPhoto } from '@/components/photo-picker'; +import { nextFreeView } from '@/components/item-photos/photo-manager'; +import { ItemGallery, type GalleryPhoto } from '@/components/item-photos/item-gallery'; +import { categoryOfType } from '@/lib/types'; + +// next/image needs the Next runtime; a plain img is enough to check what is shown. +vi.mock('next/image', () => ({ + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text + default: (props: { src: string; alt: string }) => {props.alt}, +})); + +const draft = (id: string, type: DraftPhoto['type']): DraftPhoto => ({ + id, + file: new File(['image'], `${id}.png`, { type: 'image/png' }), + preview: `blob:${id}`, + type, +}); + +function Editor({ initial }: { initial: DraftPhoto[] }) { + const [photos, setPhotos] = useState(initial); + return ( + <> + + {photos.map((p) => `${p.id}:${p.type}`).join(',')} + + ); +} + +describe('photo selection before upload', () => { + it('changes cover, labels, order and removes without uploading', () => { + vi.stubGlobal('URL', { ...URL, revokeObjectURL: vi.fn(), createObjectURL: vi.fn() }); + render(); + const order = screen.getByTestId('order'); + + fireEvent.click(screen.getAllByRole('button', { name: 'cover' })[1]); + expect(order).toHaveTextContent('back:back,front:front'); + + fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'detail' } }); + expect(order).toHaveTextContent('back:detail,front:front'); + + fireEvent.click(screen.getAllByRole('button', { name: 'next' })[0]); + expect(order).toHaveTextContent('front:front,back:detail'); + + fireEvent.click(screen.getAllByRole('button', { name: 'remove' })[1]); + expect(order).toHaveTextContent(/^front:front$/); + expect(within(screen.getAllByRole('combobox')[0]).getAllByRole('option')).toHaveLength(4); + vi.unstubAllGlobals(); + }); + + it('keeps a single front and a single back', () => { + vi.stubGlobal('URL', { ...URL, revokeObjectURL: vi.fn(), createObjectURL: vi.fn() }); + render(); + fireEvent.change(screen.getAllByRole('combobox')[1], { target: { value: 'front' } }); + expect(screen.getByTestId('order')).toHaveTextContent('a:other,b:front'); + vi.unstubAllGlobals(); + }); + + it('fills the free side first for new photos', () => { + expect(nextFreeView([])).toBe('front'); + expect(nextFreeView(['front'])).toBe('back'); + expect(nextFreeView(['back', 'front', 'detail'])).toBeNull(); + }); +}); + +describe('item gallery', () => { + const photos: GalleryPhoto[] = [ + { id: 'cover', src: '/cover.jpg', view: 'front' }, + { id: 'detail', src: '/detail.jpg', view: 'detail' }, + { id: 'back', src: '/back.jpg', view: 'back' }, + ]; + + function Gallery() { + const [index, setIndex] = useState(0); + return ; + } + + it('switches between front and back and wraps around with the arrows', () => { + render(); + const main = () => screen.getAllByRole('img')[0]; + expect(main()).toHaveAttribute('src', '/cover.jpg'); + + fireEvent.click(screen.getByRole('button', { name: 'back', pressed: false })); + expect(main()).toHaveAttribute('src', '/back.jpg'); + + fireEvent.click(screen.getByRole('button', { name: 'next' })); + expect(main()).toHaveAttribute('src', '/cover.jpg'); + + fireEvent.click(screen.getByRole('button', { name: 'previous' })); + expect(main()).toHaveAttribute('src', '/back.jpg'); + }); + + it('shows no front/back switch for a single photo', () => { + render( {}} alt="Shirt" />); + expect(screen.queryByRole('button', { name: 'previous' })).toBeNull(); + expect(screen.queryByRole('group', { name: 'frontBack' })).toBeNull(); + }); +}); + +describe('wardrobe sections', () => { + it('puts jewelry under accessories and unknown types under clothing', () => { + expect(categoryOfType('ring')).toBe('accessories'); + expect(categoryOfType('earrings')).toBe('accessories'); + expect(categoryOfType('boots')).toBe('footwear'); + expect(categoryOfType('jeans')).toBe('clothing'); + expect(categoryOfType('unknown')).toBe('clothing'); + }); +}); diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml index 0375e26dc..e3754d0c1 100644 --- a/k8s/configmap.yaml +++ b/k8s/configmap.yaml @@ -79,5 +79,5 @@ data: IMAGE_WORKER_CONCURRENCY: "1" MAX_IMAGE_MEGAPIXELS: "50" BG_REMOVAL_PROVIDER: "rembg" - BG_REMOVAL_MODEL: "u2net" + BG_REMOVAL_MODEL: "birefnet-general-lite" BG_REMOVAL_URL: ""