diff --git a/backend/app/routes/image.py b/backend/app/routes/image.py index 4fc158f..fca3aa1 100644 --- a/backend/app/routes/image.py +++ b/backend/app/routes/image.py @@ -12,12 +12,13 @@ from app.services.policy_service import ( ) from app.utils.file_validator import FileValidationError from app.utils.sanitizer import generate_safe_path -from app.tasks.image_tasks import convert_image_task, resize_image_task +from app.tasks.image_tasks import convert_image_task, resize_image_task, convert_image_to_svg_task image_bp = Blueprint("image", __name__) ALLOWED_IMAGE_TYPES = ["png", "jpg", "jpeg", "webp"] ALLOWED_OUTPUT_FORMATS = ["jpg", "png", "webp"] +ALLOWED_SVG_COLOR_MODES = ["color", "binary"] @image_bp.route("/convert", methods=["POST"]) @@ -155,3 +156,54 @@ def resize_image_route(): "task_id": task.id, "message": "Image resize started. Poll /api/tasks/{task_id}/status for progress.", }), 202 + + +@image_bp.route("/to-svg", methods=["POST"]) +@limiter.limit("10/minute") +def convert_image_to_svg_route(): + """ + Convert a raster image to SVG vector format. + + Accepts: multipart/form-data with: + - 'file': Image file (PNG, JPG, JPEG, WebP) + - 'color_mode' (optional): "color" or "binary" (default: "color") + Returns: JSON with task_id for polling + """ + if "file" not in request.files: + return jsonify({"error": "No file provided."}), 400 + + file = request.files["file"] + color_mode = request.form.get("color_mode", "color").lower() + + if color_mode not in ALLOWED_SVG_COLOR_MODES: + color_mode = "color" + + actor = resolve_web_actor() + try: + assert_quota_available(actor) + except PolicyError as e: + return jsonify({"error": e.message}), e.status_code + + try: + original_filename, ext = validate_actor_file( + file, allowed_types=ALLOWED_IMAGE_TYPES, actor=actor + ) + except FileValidationError as e: + return jsonify({"error": e.message}), e.code + + task_id, input_path = generate_safe_path(ext, folder_type="upload") + file.save(input_path) + + task = convert_image_to_svg_task.delay( + input_path, + task_id, + original_filename, + color_mode, + **build_task_tracking_kwargs(actor), + ) + record_accepted_usage(actor, "image-to-svg", task.id) + + return jsonify({ + "task_id": task.id, + "message": "Image to SVG conversion started. Poll /api/tasks/{task_id}/status for progress.", + }), 202 diff --git a/backend/app/services/image_service.py b/backend/app/services/image_service.py index e335244..82ba399 100644 --- a/backend/app/services/image_service.py +++ b/backend/app/services/image_service.py @@ -137,13 +137,14 @@ def resize_image( width = int(orig_width * ratio) # Resize using high-quality resampling + assert width is not None and height is not None resized = img.resize((width, height), Image.Resampling.LANCZOS) # Detect format from output extension ext = os.path.splitext(output_path)[1].lower().strip(".") pil_format = FORMAT_MAP.get(ext, "PNG") - save_kwargs = {"optimize": True} + save_kwargs: dict[str, int | bool] = {"optimize": True} if pil_format in ("JPEG", "WEBP"): save_kwargs["quality"] = quality # Handle RGBA for JPEG @@ -167,3 +168,67 @@ def resize_image( except (IOError, OSError, Image.DecompressionBombError) as e: raise ImageProcessingError(f"Image resize failed: {str(e)}") + + +# ─── Allowed color modes for SVG tracing ───────────────────── +ALLOWED_COLOR_MODES = ("color", "binary") + + +def convert_image_to_svg( + input_path: str, + output_path: str, + color_mode: str = "color", +) -> dict: + """ + Convert a raster image to SVG using vtracer. + + Args: + input_path: Path to the input image (PNG, JPG, WebP) + output_path: Path for the output SVG file + color_mode: "color" for full-colour trace, "binary" for black & white + + Returns: + dict with original_size, converted_size, width, height + + Raises: + ImageProcessingError: If conversion fails + """ + import vtracer + + if color_mode not in ALLOWED_COLOR_MODES: + color_mode = "color" + + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + try: + original_size = os.path.getsize(input_path) + + # Read dimensions via Pillow (also validates image) + with Image.open(input_path) as img: + width, height = img.size + + vtracer.convert_image_to_svg_py( + input_path, + output_path, + colormode=color_mode, + ) + + converted_size = os.path.getsize(output_path) + + logger.info( + f"Image→SVG conversion: {input_path} " + f"({original_size} → {converted_size})" + ) + + return { + "original_size": original_size, + "converted_size": converted_size, + "width": width, + "height": height, + "format": "svg", + } + + except (IOError, OSError, Image.DecompressionBombError) as e: + raise ImageProcessingError(f"Image to SVG conversion failed: {str(e)}") + except Exception as e: + raise ImageProcessingError(f"Image to SVG conversion failed: {str(e)}") diff --git a/backend/app/tasks/image_tasks.py b/backend/app/tasks/image_tasks.py index 661bab9..a92be1a 100644 --- a/backend/app/tasks/image_tasks.py +++ b/backend/app/tasks/image_tasks.py @@ -5,7 +5,7 @@ import logging from flask import current_app from app.extensions import celery -from app.services.image_service import convert_image, resize_image, ImageProcessingError +from app.services.image_service import convert_image, resize_image, convert_image_to_svg, ImageProcessingError from app.services.storage_service import storage from app.services.task_tracking_service import finalize_task_tracking from app.utils.sanitizer import cleanup_task_files @@ -242,3 +242,98 @@ def resize_image_task( api_key_id, self.request.id, ) + + +@celery.task(bind=True, name="app.tasks.image_tasks.convert_image_to_svg_task") +def convert_image_to_svg_task( + self, + input_path: str, + task_id: str, + original_filename: str, + color_mode: str = "color", + user_id: int | None = None, + usage_source: str = "web", + api_key_id: int | None = None, +): + """ + Async task: Convert a raster image to SVG. + + Args: + input_path: Path to the uploaded image + task_id: Unique task identifier + original_filename: Original filename for download + color_mode: "color" or "binary" + + Returns: + dict with download_url and conversion stats + """ + output_dir = _get_output_dir(task_id) + output_path = os.path.join(output_dir, f"{task_id}.svg") + + try: + self.update_state( + state="PROCESSING", + meta={"step": "Converting image to SVG..."}, + ) + + stats = convert_image_to_svg(input_path, output_path, color_mode) + + self.update_state(state="PROCESSING", meta={"step": "Uploading result..."}) + + s3_key = storage.upload_file(output_path, task_id, folder="outputs") + + name_without_ext = os.path.splitext(original_filename)[0] + download_name = f"{name_without_ext}.svg" + + download_url = storage.generate_presigned_url( + s3_key, original_filename=download_name + ) + + result = { + "status": "completed", + "download_url": download_url, + "filename": download_name, + "original_size": stats["original_size"], + "converted_size": stats["converted_size"], + "width": stats["width"], + "height": stats["height"], + "format": "svg", + } + + logger.info(f"Task {task_id}: Image to SVG conversion completed") + return _finalize_task( + task_id, + user_id, + "image-to-svg", + original_filename, + result, + usage_source, + api_key_id, + self.request.id, + ) + + except ImageProcessingError as e: + logger.error(f"Task {task_id}: Image error — {e}") + return _finalize_task( + task_id, + user_id, + "image-to-svg", + original_filename, + {"status": "failed", "error": str(e)}, + usage_source, + api_key_id, + self.request.id, + ) + + except Exception as e: + logger.exception(f"Task {task_id}: Unexpected error — {e}") + return _finalize_task( + task_id, + user_id, + "image-to-svg", + original_filename, + {"status": "failed", "error": str(e) or "An unexpected error occurred."}, + usage_source, + api_key_id, + self.request.id, + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index f0df987..de22bc9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -41,6 +41,9 @@ python-pptx>=0.6.21,<2.0 # Barcode Generation python-barcode>=0.15,<1.0 +# Image to SVG (Raster to Vector) +vtracer>=0.6,<1.0 + # Background Removal rembg>=2.0,<3.0 onnxruntime>=1.16,<2.0 diff --git a/frontend/index.html b/frontend/index.html index b5682ff..6478bbb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,6 +6,7 @@ + diff --git a/frontend/public/social-preview.svg b/frontend/public/social-preview.svg index 42fcece..61d6707 100644 Binary files a/frontend/public/social-preview.svg and b/frontend/public/social-preview.svg differ diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..36c5355 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,8 @@ +{ + "venvPath": ".", + "venv": ".venv", + "pythonVersion": "3.11", + "include": [ + "backend" + ] +}