From d810e08153f0593201a50f7cf599839c801d7886 Mon Sep 17 00:00:00 2001 From: TheBlackSide243 <125499423+TheBlackSide243@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:45:11 +0200 Subject: [PATCH 1/6] feat: remove the background while uploading Co-Authored-By: Claude Opus 5.5 --- backend/app/api/items.py | 43 ++++++-- backend/app/workers/background_removal.py | 50 +++++++-- backend/tests/test_upload_background.py | 126 ++++++++++++++++++++++ frontend/components/add-item-dialog.tsx | 62 +++++++++++ frontend/lib/hooks/use-items.ts | 39 +++++-- frontend/lib/upload-manager.ts | 8 +- frontend/lib/upload-queue.ts | 6 +- frontend/messages/de/wardrobe.json | 4 +- frontend/messages/en/wardrobe.json | 4 +- frontend/messages/fr/wardrobe.json | 4 +- frontend/messages/it/wardrobe.json | 4 +- frontend/messages/ja/wardrobe.json | 4 +- frontend/messages/ko/wardrobe.json | 4 +- frontend/messages/zh-CN/wardrobe.json | 4 +- frontend/messages/zh-TW/wardrobe.json | 4 +- 15 files changed, 330 insertions(+), 36 deletions(-) create mode 100644 backend/tests/test_upload_background.py diff --git a/backend/app/api/items.py b/backend/app/api/items.py index 3bb2fc6c6..25369b64f 100644 --- a/backend/app/api/items.py +++ b/backend/app/api/items.py @@ -186,6 +186,31 @@ async def list_items( ) +UPLOAD_BACKGROUND = "#FFFFFF" + + +async def _queue_upload_processing( + redis, item: ClothingItem, full_image_path: str, remove_background: bool +): + """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.""" + 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, + _queue_name=IMAGE_QUEUE, + ) + return await redis.enqueue_job( + "tag_item_image", + str(item.id), + full_image_path, + _queue_name=TAGGING_QUEUE, + ) + + @router.post("", response_model=ItemResponse, status_code=status.HTTP_201_CREATED) async def create_item( db: DbSession, @@ -200,6 +225,7 @@ async def create_item( primary_color: str | None = Form(None), favorite: bool = Form(False), skip_ai: bool = Form(False), + remove_background: bool = Form(False), ) -> ItemResponse: # Validate and process image image_service = ImageService() @@ -274,11 +300,8 @@ 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 ) item.ai_job_id = job.job_id await db.commit() @@ -300,6 +323,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. " @@ -445,11 +471,8 @@ 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 ) item.ai_job_id = job.job_id await db.commit() diff --git a/backend/app/workers/background_removal.py b/backend/app/workers/background_removal.py index 4563e6e1e..e1758a2d6 100644 --- a/backend/app/workers/background_removal.py +++ b/backend/app/workers/background_removal.py @@ -8,16 +8,23 @@ from app.models.item import ClothingItem, 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,27 @@ 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 diff --git a/backend/tests/test_upload_background.py b/backend/tests/test_upload_background.py new file mode 100644 index 000000000..6abb4e5fb --- /dev/null +++ b/backend/tests/test_upload_background.py @@ -0,0 +1,126 @@ +"""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 + assert redis.enqueue_job.call_args.args[0] == "tag_item_image" + + +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/frontend/components/add-item-dialog.tsx b/frontend/components/add-item-dialog.tsx index b324234e8..46ca2255d 100644 --- a/frontend/components/add-item-dialog.tsx +++ b/frontend/components/add-item-dialog.tsx @@ -51,6 +51,50 @@ interface FileWithPreview { id: string; } +const REMOVE_BACKGROUND_KEY = 'wardrowbe.removeBackground'; + +// Remembered per browser; on by default so new photos come out clean. +function readRemoveBackground(): boolean { + try { + return window.localStorage.getItem(REMOVE_BACKGROUND_KEY) !== 'false'; + } catch { + return true; + } +} + +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. + } +} + +function RemoveBackgroundOption({ + checked, + onChange, +}: { + checked: boolean; + onChange: (value: boolean) => void; +}) { + const t = useTranslations('wardrobe.addItem'); + return ( +
+ onChange(value === true)} + /> +
+ +

{t('removeBackgroundHint')}

+
+
+ ); +} + export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) { const t = useTranslations('wardrobe.addItem'); const tc = useTranslations('common'); @@ -69,6 +113,7 @@ export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) { const [bulkFiles, setBulkFiles] = useState([]); const [bulkResult, setBulkResult] = useState(null); const [skipAi, setSkipAi] = useState(false); + const [removeBackground, setRemoveBackground] = useState(readRemoveBackground); const [activeTab, setActiveTab] = useState('single'); const [showCloseConfirm, setShowCloseConfirm] = useState(false); @@ -139,6 +184,7 @@ export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) { if (brand) formData.append('brand', brand); if (primaryColor) formData.append('primary_color', primaryColor); if (notes) formData.append('notes', notes); + formData.append('remove_background', String(removeBackground)); try { await createItem.mutateAsync(formData); @@ -155,6 +201,7 @@ export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) { const result = await bulkCreateItems.mutateAsync({ files: bulkFiles.map((f) => f.file), skipAi, + removeBackground, }); if (result.staged > 0) { @@ -378,6 +425,14 @@ export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) { + { + setRemoveBackground(value); + storeRemoveBackground(value); + }} + /> +
)} diff --git a/frontend/lib/hooks/use-items.ts b/frontend/lib/hooks/use-items.ts index b19c70573..e66cdfeec 100644 --- a/frontend/lib/hooks/use-items.ts +++ b/frontend/lib/hooks/use-items.ts @@ -1038,13 +1038,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 +1117,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 +1131,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 +1252,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 +1278,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/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/wardrobe.json b/frontend/messages/de/wardrobe.json index 37a0ea3fe..4fad3e129 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", diff --git a/frontend/messages/en/wardrobe.json b/frontend/messages/en/wardrobe.json index 7794b71a5..220c6bdf5 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", diff --git a/frontend/messages/fr/wardrobe.json b/frontend/messages/fr/wardrobe.json index f9c91c0ff..ccd8ec719 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", diff --git a/frontend/messages/it/wardrobe.json b/frontend/messages/it/wardrobe.json index 278489d4f..451298ad5 100644 --- a/frontend/messages/it/wardrobe.json +++ b/frontend/messages/it/wardrobe.json @@ -45,7 +45,9 @@ "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", diff --git a/frontend/messages/ja/wardrobe.json b/frontend/messages/ja/wardrobe.json index 835c2df16..a6636ef25 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": "分析失敗", diff --git a/frontend/messages/ko/wardrobe.json b/frontend/messages/ko/wardrobe.json index 96a0ee3b6..0d45398a2 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": "분석 실패", diff --git a/frontend/messages/zh-CN/wardrobe.json b/frontend/messages/zh-CN/wardrobe.json index 58a43acf5..9b24ee6c2 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": "分析失败", diff --git a/frontend/messages/zh-TW/wardrobe.json b/frontend/messages/zh-TW/wardrobe.json index 4892c17d9..718493eb4 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": "分析失敗", From abc780e6728f17080e11a6c998e20dccd502fc5f Mon Sep 17 00:00:00 2001 From: TheBlackSide243 <125499423+TheBlackSide243@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:45:11 +0200 Subject: [PATCH 2/6] feat: more accurate background removal for clothes Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- backend/Dockerfile | 2 +- backend/app/config.py | 2 +- backend/app/services/background_removal.py | 2 +- backend/app/services/image_service.py | 20 ++- backend/app/services/mask_cleanup.py | 65 +++++++++ backend/tests/test_mask_cleanup.py | 156 +++++++++++++++++++++ docker-compose.prod.yml | 2 +- docker-compose.yml | 2 +- k8s/configmap.yaml | 2 +- 10 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 backend/app/services/mask_cleanup.py create mode 100644 backend/tests/test_mask_cleanup.py diff --git a/README.md b/README.md index cb8c61efe..eb4bb0b25 100644 --- a/README.md +++ b/README.md @@ -319,7 +319,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 | @@ -399,12 +399,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/config.py b/backend/app/config.py index 6fd87af5b..d3edf04c4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -167,7 +167,7 @@ def normalize_reasoning_effort(cls, v: str | None) -> str | None: # 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 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/services/background_removal.py b/backend/app/services/background_removal.py index 6a5c7accb..4a1ec28b1 100644 --- a/backend/app/services/background_removal.py +++ b/backend/app/services/background_removal.py @@ -19,7 +19,7 @@ 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"): + def __init__(self, model: str = "birefnet-general-lite"): self.model = model self._session = None diff --git a/backend/app/services/image_service.py b/backend/app/services/image_service.py index ea65db728..f33763405 100644 --- a/backend/app/services/image_service.py +++ b/backend/app/services/image_service.py @@ -11,6 +11,7 @@ from app.config import get_settings from app.services import background_removal +from app.services.mask_cleanup import clean_cutout settings = get_settings() @@ -260,15 +261,19 @@ 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) - # Composite onto solid color background background = Image.new("RGBA", result.size, (*bg_color, 255)) background.paste(result, mask=result.split()[3]) @@ -310,4 +315,11 @@ def rotate_image(self, image_path: str, direction: str = "cw") -> dict[str, str] rotated = image.rotate(angle, expand=True) + # The background-removal backup turns with the photo, so undoing or redoing + # the removal later keeps the rotation. + backup_full = self.storage_path / f"{image_path.rsplit('.', 1)[0]}_orig.jpg" + if backup_full.exists(): + backup = _flatten_to_rgb(Image.open(backup_full)).rotate(angle, expand=True) + backup.save(backup_full, "JPEG", quality=95) + return self._save_all_sizes(rotated, image_path) diff --git a/backend/app/services/mask_cleanup.py b/backend/app/services/mask_cleanup.py new file mode 100644 index 000000000..076db5b53 --- /dev/null +++ b/backend/app/services/mask_cleanup.py @@ -0,0 +1,65 @@ +"""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. +MIN_COVERAGE = 0.02 +# Pieces smaller than this share of the biggest one are background specks. Generous +# enough that the second shoe of a pair or a sock is always kept. +KEEP_RATIO = 0.05 +# 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/tests/test_mask_cleanup.py b/backend/tests/test_mask_cleanup.py new file mode 100644 index 000000000..26a3548e7 --- /dev/null +++ b/backend/tests/test_mask_cleanup.py @@ -0,0 +1,156 @@ +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_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_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/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..377734e7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -158,7 +158,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/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: "" From 219a7f68f7c1ef0a7e0ccfe78de3727e97c85f9c Mon Sep 17 00:00:00 2001 From: TheBlackSide243 <125499423+TheBlackSide243@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:13:38 +0200 Subject: [PATCH 3/6] fix(upload): background removal runs even when tagging is off The upload handlers queued background removal only together with AI tagging, so with tagging skipped or disabled the option was silently ignored. The removal is now queued on its own and the worker marks the item ready; a failed enqueue no longer strands the item in processing. The 'only tags' test asserts the single tagging job and its queue. Co-Authored-By: Claude Opus 5.5 --- backend/app/api/items.py | 33 ++++++++++++++++--------- backend/tests/test_upload_background.py | 28 ++++++++++++++++++++- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/backend/app/api/items.py b/backend/app/api/items.py index 25369b64f..1e8cbf161 100644 --- a/backend/app/api/items.py +++ b/backend/app/api/items.py @@ -190,17 +190,18 @@ async def list_items( async def _queue_upload_processing( - redis, item: ClothingItem, full_image_path: str, remove_background: bool + 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.""" + 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, + full_image_path if tag else None, _queue_name=IMAGE_QUEUE, ) return await redis.enqueue_job( @@ -291,7 +292,8 @@ async def create_item( 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. @@ -301,16 +303,19 @@ async def create_item( try: full_image_path = str(image_service.get_image_path(image_paths["image_path"])) job = await _queue_upload_processing( - redis, item, full_image_path, remove_background + 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) @@ -364,9 +369,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: @@ -459,7 +466,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 @@ -472,14 +479,16 @@ async def bulk_create_items( image_service.get_image_path(image_paths["image_path"]) ) job = await _queue_upload_processing( - redis, item, full_image_path, remove_background + 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( diff --git a/backend/tests/test_upload_background.py b/backend/tests/test_upload_background.py index 6abb4e5fb..e6913eff8 100644 --- a/backend/tests/test_upload_background.py +++ b/backend/tests/test_upload_background.py @@ -77,7 +77,33 @@ async def test_without_the_option_only_tags(self, client: AsyncClient, auth_head headers=auth_headers, ) assert response.status_code == 201 - assert redis.enqueue_job.call_args.args[0] == "tag_item_image" + 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: From 65796dc7453664dd2d2f24ca9ce70e8695c61c7f Mon Sep 17 00:00:00 2001 From: TheBlackSide243 <125499423+TheBlackSide243@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:15:06 +0200 Subject: [PATCH 4/6] fix(background-removal): address review findings - Backups are written through a temporary file and swapped in, so an interrupted write never leaves a truncated original to restore from. - Rotation turns the backup only after the photo and its sizes are saved. - Smaller detached parts (straps, hoods) are kept and small garments such as jewellery are no longer rejected as empty cut-outs. Co-Authored-By: Claude Opus 5.5 --- backend/app/services/image_service.py | 24 ++++++++++--- backend/app/services/mask_cleanup.py | 9 ++--- backend/tests/test_mask_cleanup.py | 49 +++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/backend/app/services/image_service.py b/backend/app/services/image_service.py index f33763405..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 @@ -60,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 @@ -272,7 +284,7 @@ def remove_background( # 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) + _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)) @@ -316,10 +328,14 @@ def rotate_image(self, image_path: str, direction: str = "cw") -> dict[str, str] rotated = image.rotate(angle, expand=True) # The background-removal backup turns with the photo, so undoing or redoing - # the removal later keeps the rotation. + # 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) - backup.save(backup_full, "JPEG", quality=95) - return self._save_all_sizes(rotated, image_path) + 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/mask_cleanup.py b/backend/app/services/mask_cleanup.py index 076db5b53..87138ed16 100644 --- a/backend/app/services/mask_cleanup.py +++ b/backend/app/services/mask_cleanup.py @@ -10,11 +10,12 @@ from PIL import Image from scipy import ndimage -# Less opaque area than this and the model lost the garment altogether. -MIN_COVERAGE = 0.02 +# 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 or a sock is always kept. -KEEP_RATIO = 0.05 +# 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 diff --git a/backend/tests/test_mask_cleanup.py b/backend/tests/test_mask_cleanup.py index 26a3548e7..cb531cd52 100644 --- a/backend/tests/test_mask_cleanup.py +++ b/backend/tests/test_mask_cleanup.py @@ -60,6 +60,19 @@ def test_keeps_both_shoes_of_a_pair(self): 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)])) @@ -139,6 +152,42 @@ async def test_failed_removal_leaves_no_backup(self, item: ClothingItem): 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() From da742d63f686c98326b8568ed09abb038dd0b7ad Mon Sep 17 00:00:00 2001 From: TheBlackSide243 <125499423+TheBlackSide243@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:24:48 +0200 Subject: [PATCH 5/6] feat: several photos per item with front/back views, accessories and per-photo background removal - Up to 10 photos per item; each can be labelled front, back, detail or other (item_photo_views table, keyed by the photo's file so the label follows it when it becomes the cover). The gallery and the card show the back on hover. - Background removal runs per photo on the image worker, with its own status and backup (item_images.status / original_image_path / processing_started_at); the item's button and the bulk action also clean the other photos, and each photo can be restored. - Photos are shown whole (object-fit: contain), never cropped. - Accessory types (ring, necklace, watch, bracelet, earrings) and a category filter (tops, bottoms, outerwear, shoes, accessories...) on the wardrobe page, with a category breakdown in analytics. - A softer colour palette with category, success, warning and info tokens. - The image worker caps rembg's threads and frees the model after 10 minutes idle (BG_REMOVAL_THREADS, BG_REMOVAL_IDLE_SECONDS). Builds on #277 and #278 (merged in here); the diff shrinks once they land. Co-Authored-By: Claude Opus 5.5 --- backend/app/api/analytics.py | 25 ++ backend/app/api/items.py | 371 +++++++++++++--- backend/app/config.py | 6 + backend/app/data/garment_vocabulary.json | 12 +- backend/app/models/__init__.py | 9 +- backend/app/models/item.py | 44 ++ backend/app/schemas/item.py | 51 ++- backend/app/services/background_removal.py | 37 +- backend/app/services/item_service.py | 12 + backend/app/utils/garment_vocabulary.py | 10 + backend/app/workers/background_removal.py | 41 +- backend/app/workers/image_worker.py | 6 +- .../a4c6e8f0b2d3_item_image_processing.py | 40 ++ .../versions/f3b5c7d9e1a2_item_photo_views.py | 40 ++ .../test_bulk_rotate_and_remove_background.py | 1 + backend/tests/test_deploy_env.py | 6 +- backend/tests/test_image_undo_replace.py | 77 +++- backend/tests/test_image_worker.py | 6 +- .../tests/test_photo_views_and_accessories.py | 413 ++++++++++++++++++ backend/tests/test_remove_bg_endpoint.py | 89 +++- docker-compose.yml | 4 + frontend/app/dashboard/analytics/page.tsx | 69 ++- frontend/app/dashboard/family/feed/page.tsx | 2 +- frontend/app/dashboard/learning/page.tsx | 4 +- frontend/app/dashboard/outfits/[id]/page.tsx | 2 +- frontend/app/dashboard/page.tsx | 2 +- frontend/app/dashboard/suggest/page.tsx | 4 +- frontend/app/dashboard/wardrobe/page.tsx | 102 +++-- frontend/app/globals.css | 100 +++-- frontend/app/onboarding/page.tsx | 2 +- frontend/components/add-item-dialog.tsx | 168 +++---- frontend/components/analysis-queue-panel.tsx | 2 +- frontend/components/feedback-dialog.tsx | 2 +- .../components/generate-pairings-dialog.tsx | 4 +- frontend/components/item-detail-dialog.tsx | 198 +++------ .../components/item-photos/card-photo.tsx | 85 ++++ .../components/item-photos/category-badge.tsx | 49 +++ .../item-photos/category-type-select.tsx | 57 +++ .../components/item-photos/item-gallery.tsx | 163 +++++++ .../components/item-photos/photo-manager.tsx | 333 ++++++++++++++ frontend/components/mobile-sidebar.tsx | 4 +- frontend/components/outfit-history-card.tsx | 4 +- frontend/components/outfit-preview-dialog.tsx | 2 +- frontend/components/outfits/outfit-card.tsx | 2 +- frontend/components/pairing-card.tsx | 4 +- frontend/components/photo-picker.tsx | 200 +++++++++ frontend/components/shared/item-picker.tsx | 2 +- frontend/components/sidebar.tsx | 4 +- frontend/components/studio/canvas-panel.tsx | 2 +- frontend/lib/generated/garment-vocabulary.ts | 23 +- frontend/lib/hooks/use-analytics.ts | 9 + frontend/lib/hooks/use-items.ts | 89 +++- .../lib/hooks/use-translated-constants.ts | 36 ++ frontend/lib/remove-background-pref.ts | 19 + frontend/lib/types.ts | 29 +- frontend/messages/de/analytics.json | 6 +- frontend/messages/de/constants.json | 12 +- frontend/messages/de/wardrobe.json | 41 ++ frontend/messages/en/analytics.json | 6 +- frontend/messages/en/constants.json | 12 +- frontend/messages/en/wardrobe.json | 43 +- frontend/messages/fr/analytics.json | 6 +- frontend/messages/fr/constants.json | 12 +- frontend/messages/fr/wardrobe.json | 41 ++ frontend/messages/it/analytics.json | 6 +- frontend/messages/it/constants.json | 12 +- frontend/messages/it/wardrobe.json | 151 ++++--- frontend/messages/ja/analytics.json | 6 +- frontend/messages/ja/constants.json | 12 +- frontend/messages/ja/wardrobe.json | 41 ++ frontend/messages/ko/analytics.json | 6 +- frontend/messages/ko/constants.json | 12 +- frontend/messages/ko/wardrobe.json | 41 ++ frontend/messages/zh-CN/analytics.json | 6 +- frontend/messages/zh-CN/constants.json | 12 +- frontend/messages/zh-CN/wardrobe.json | 41 ++ frontend/messages/zh-TW/analytics.json | 6 +- frontend/messages/zh-TW/constants.json | 12 +- frontend/messages/zh-TW/wardrobe.json | 41 ++ frontend/scripts/gen-shared-data.mjs | 11 +- frontend/tailwind.config.js | 8 + frontend/tests/photo-picker.test.tsx | 110 +++++ 82 files changed, 3274 insertions(+), 516 deletions(-) create mode 100644 backend/migrations/versions/a4c6e8f0b2d3_item_image_processing.py create mode 100644 backend/migrations/versions/f3b5c7d9e1a2_item_photo_views.py create mode 100644 backend/tests/test_photo_views_and_accessories.py create mode 100644 frontend/components/item-photos/card-photo.tsx create mode 100644 frontend/components/item-photos/category-badge.tsx create mode 100644 frontend/components/item-photos/category-type-select.tsx create mode 100644 frontend/components/item-photos/item-gallery.tsx create mode 100644 frontend/components/item-photos/photo-manager.tsx create mode 100644 frontend/components/photo-picker.tsx create mode 100644 frontend/lib/remove-background-pref.ts create mode 100644 frontend/tests/photo-picker.test.tsx 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 25369b64f..0d12727eb 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, @@ -211,6 +222,104 @@ async def _queue_upload_processing( ) +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, @@ -226,6 +335,7 @@ async def create_item( 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() @@ -288,6 +398,8 @@ 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 @@ -575,14 +687,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 @@ -1045,11 +1150,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, @@ -1336,15 +1452,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) @@ -1809,52 +1917,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) @@ -1967,6 +2090,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) @@ -1979,6 +2110,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) @@ -1989,13 +2122,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 @@ -2031,6 +2165,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) @@ -2069,9 +2208,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() @@ -2102,10 +2250,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, @@ -2133,20 +2377,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 d3edf04c4..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="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 4a1ec28b1..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 = "birefnet-general-lite"): + """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/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/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 e1758a2d6..4b7cbd5c4 100644 --- a/backend/app/workers/background_removal.py +++ b/backend/app/workers/background_removal.py @@ -5,7 +5,7 @@ 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 @@ -94,3 +94,42 @@ async def _queue_tagging(ctx: dict, item: ClothingItem, image_path: str) -> None # 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_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_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/docker-compose.yml b/docker-compose.yml index 377734e7b..0319813e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -161,6 +161,10 @@ services: 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')} - -
- )} +
- +
@@ -439,9 +383,9 @@ export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) {
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 e66cdfeec..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; 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/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 4fad3e129..3d68a1891 100644 --- a/frontend/messages/de/wardrobe.json +++ b/frontend/messages/de/wardrobe.json @@ -279,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 220c6bdf5..aa73aa083 100644 --- a/frontend/messages/en/wardrobe.json +++ b/frontend/messages/en/wardrobe.json @@ -106,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}}", @@ -279,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 ccd8ec719..0d21f0689 100644 --- a/frontend/messages/fr/wardrobe.json +++ b/frontend/messages/fr/wardrobe.json @@ -279,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 451298ad5..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)", @@ -53,69 +53,69 @@ "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.", @@ -255,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", @@ -279,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 a6636ef25..da9d71704 100644 --- a/frontend/messages/ja/wardrobe.json +++ b/frontend/messages/ja/wardrobe.json @@ -279,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 0d45398a2..bc2571ea7 100644 --- a/frontend/messages/ko/wardrobe.json +++ b/frontend/messages/ko/wardrobe.json @@ -279,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 9b24ee6c2..13d417a4b 100644 --- a/frontend/messages/zh-CN/wardrobe.json +++ b/frontend/messages/zh-CN/wardrobe.json @@ -279,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 718493eb4..ab831949f 100644 --- a/frontend/messages/zh-TW/wardrobe.json +++ b/frontend/messages/zh-TW/wardrobe.json @@ -279,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'); + }); +}); From 24cda40976c5229732f69e113b20d9a0d49be21b Mon Sep 17 00:00:00 2001 From: TheBlackSide243 <125499423+TheBlackSide243@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:27:28 +0200 Subject: [PATCH 6/6] test: the background-removal model is freed after idle time Co-Authored-By: Claude Opus 5.5 --- backend/tests/test_background_unload.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 backend/tests/test_background_unload.py 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