handicraft_editor / src /streamlit_app.py
Deepika-05's picture
Update src/streamlit_app.py
0a0bcbc verified
Raw
History Blame Contribute Delete
50.7 kB
# Import modular components
import os
os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "false"
from agno.models.google import Gemini
import base64
import time
import cv2
import numpy as np
import streamlit as st
from components.sidebar import render_sidebar
from services.gemini_service import (
analyze_feasibility_with_gemini,
refine_svg_with_gemini,
)
from services.image_processor import skeletonize_image
from services.pdf_processor import process_pdf
from services.vectorizer import generate_svg
# Import rembg for background isolation
try:
from rembg import remove
REMBG_AVAILABLE = True
except ImportError:
REMBG_AVAILABLE = False
# Attempt to import PyMuPDF
try:
import fitz # PyMuPDF
PDF_SUPPORT_AVAILABLE = True
except ImportError:
PDF_SUPPORT_AVAILABLE = False
from config.session import init_session, reset_generation_state
# -------------------------------------------------------------
# SAFE CALLBACK FUNCTIONS (Prevents State Exceptions)
# -------------------------------------------------------------
def load_recommended_settings(params):
"""Stage 1: Safely sets baseline AI parameter recommendations."""
st.session_state.quality_val = params.get("quality_val", "Moderate (Balanced)")
st.session_state.sharpen_val = float(params.get("sharpen_val", 0.0))
st.session_state.scale_val = float(params.get("scale_val", 2.0))
st.session_state.canny_low_val = int(params.get("canny_low_val", 10))
st.session_state.canny_high_val = int(params.get("canny_high_val", 70))
st.session_state.skeletonize_val = bool(params.get("skeletonize_val", False))
def load_refined_settings(params):
"""Stage 2: Safely sets fine-tuned refinement tweaks."""
st.session_state.quality_val = params.get("quality_val", st.session_state.quality_val)
st.session_state.sharpen_val = float(params.get("sharpen_val", st.session_state.sharpen_val))
st.session_state.scale_val = float(params.get("scale_val", st.session_state.scale_val))
st.session_state.canny_low_val = int(params.get("canny_low_val", st.session_state.canny_low_val))
st.session_state.canny_high_val = int(params.get("canny_high_val", st.session_state.canny_high_val))
st.session_state.skeletonize_val = bool(params.get("skeletonize_val", st.session_state.skeletonize_val))
st.session_state.contour_thickness_val = int(params.get("contour_thickness_val", st.session_state.contour_thickness_val))
st.session_state.dilate_iter_val = int(params.get("dilate_iter_val", st.session_state.dilate_iter_val))
st.set_page_config(page_title="Handicraft Image Workspace", layout="wide")
init_session()
# Initialize State management if missing
if "pipeline_triggered" not in st.session_state:
st.session_state.pipeline_triggered = False
if "advisor_insights" not in st.session_state:
st.session_state.advisor_insights = None
if "advisor_insights_dynamic" not in st.session_state:
st.session_state.advisor_insights_dynamic = None
# -------------------------------------------------------------
# CALL THE MODULAR SIDEBAR COMPONENT HERE
# -------------------------------------------------------------
app_page = render_sidebar(reset_generation_state)
# =============================================================
# PAGE 1: ORIGINAL IMAGE-TO-SVG VECTORIZER
# =============================================================
if app_page == "🎨 Image-to-SVG Vectorizer":
st.markdown("# 🎨 Advanced Handicraft Image & PDF Vectorizer")
st.markdown("Convert photos, sketches, drawings, or multi-page PDF documents into clean, editable vector SVGs with strict AI guidance.")
# PDF Processing Mode
if PDF_SUPPORT_AVAILABLE:
st.subheader("📄 PDF Processing Mode")
pdf_mode = st.radio(
"Select how files should be processed:",
["Extract Embedded Images", "Render Entire Pages"],
index=0 if st.session_state.pdf_mode_val == "Extract Embedded Images" else 1,
help="Extract Embedded Images scans inside the PDF to fetch actual photographic contents. Render Entire Pages snapshots each full page."
)
if pdf_mode != st.session_state.pdf_mode_val:
st.session_state.pdf_mode_val = pdf_mode
st.session_state.current_file_name = ""
reset_generation_state()
st.session_state.pipeline_triggered = False
# File Uploader - Configured to allow multiple uploads for target comparison
supported_formats = ["png", "jpg", "jpeg"]
if PDF_SUPPORT_AVAILABLE:
supported_formats.append("pdf")
file_help = "Upload one or more alternative photos of your handicraft (JPG, PNG, or PDF) to let AI evaluate the cleanest candidate."
else:
file_help = "Upload JPG/PNG images. Note: Install 'pymupdf' (pip install pymupdf) to unlock PDF support."
uploaded_files = st.file_uploader(file_help, type=supported_formats, accept_multiple_files=True, key="vectorizer_uploader")
if uploaded_files:
# Check if the compilation batch changed
batch_signature = "".join([f.name for f in uploaded_files])
if batch_signature != st.session_state.get("last_batch_signature", ""):
st.session_state.last_batch_signature = batch_signature
st.session_state.extracted_images = []
st.session_state.selected_img_index = 0
st.session_state.advisor_insights = None
st.session_state.advisor_insights_dynamic = None
reset_generation_state()
st.session_state.pipeline_triggered = False
# Process single or multiple uploaded items into session state assets
for f in uploaded_files:
file_name = f.name
if file_name.lower().endswith(".pdf") and PDF_SUPPORT_AVAILABLE:
pdf_data = f.read()
st.session_state.extracted_images.extend(process_pdf(pdf_data, st.session_state.pdf_mode_val))
else:
file_bytes_raw = f.read()
mime_type = "image/png" if f.type == "image/png" else "image/jpeg"
st.session_state.extracted_images.append({
"name": file_name,
"bytes": file_bytes_raw,
"mime_type": mime_type,
"analysis": None,
"params": None
})
total_images = len(st.session_state.extracted_images)
# -------------------------------------------------------------
# IMAGE SELECTION & SELECTIVE AI COMPARISON AGENT
# -------------------------------------------------------------
if total_images > 1:
st.info(f"📄 **Multiple Variants Detected:** Found **{total_images}** target variants for evaluation.")
# Auto-run strict feasibility checks across all items to recommend the best asset
with st.spinner("🤖 Strict AI Assessment Engine evaluating all files for trace compatibility..."):
for idx, img in enumerate(st.session_state.extracted_images):
if img["analysis"] is None:
# Append instructions to enforce strict evaluation rules in the backend framework
strict_result = analyze_feasibility_with_gemini(img["bytes"], img["mime_type"])
st.session_state.extracted_images[idx]["analysis"] = strict_result
if isinstance(strict_result, dict) and "recommended_parameters" in strict_result:
st.session_state.extracted_images[idx]["params"] = strict_result["recommended_parameters"]
# Construct comparison board UI
st.markdown("### 📊 AI Cross-Image Comparison Analysis")
comparison_data = []
for img in st.session_state.extracted_images:
an = img["analysis"] if isinstance(img["analysis"], dict) else {}
score = an.get("suitability_score", "N/A")
suit = "👍 High Pass" if an.get("suitable", False) else "⚠️ Low/Noisy (Rejected)"
comparison_data.append({
"Asset Name": img["name"],
"Traceability Score (Strict)": f"{score}/100",
"Status Decision": suit,
"Lighting/Contrast Note": an.get("lighting_critique", "N/A")[:90] + "..."
})
st.table(comparison_data)
selected_page_name = st.selectbox(
"Select your preferred item to load into Workspace:",
options=[img["name"] for img in st.session_state.extracted_images],
index=st.session_state.selected_img_index
)
new_idx = [img["name"] for img in st.session_state.extracted_images].index(selected_page_name)
if new_idx != st.session_state.selected_img_index:
st.session_state.selected_img_index = new_idx
st.session_state.advisor_insights = None
st.session_state.advisor_insights_dynamic = None
reset_generation_state()
st.session_state.pipeline_triggered = False
st.rerun()
else:
# Single Image Upload: Automatically run strict analysis instantly without button gate
if st.session_state.extracted_images[0]["analysis"] is None:
with st.spinner("🤖 Initializing Strict Visual Feasibility Assessment..."):
img = st.session_state.extracted_images[0]
strict_result = analyze_feasibility_with_gemini(img["bytes"], img["mime_type"])
st.session_state.extracted_images[0]["analysis"] = strict_result
if isinstance(strict_result, dict) and "recommended_parameters" in strict_result:
st.session_state.extracted_images[0]["params"] = strict_result["recommended_parameters"]
# Finalize context around the Active Target Item
active_img = st.session_state.extracted_images[st.session_state.selected_img_index]
active_bytes = active_img["bytes"]
active_mime = active_img["mime_type"]
st.session_state.original_img_b64 = f"data:{active_mime};base64," + base64.b64encode(active_bytes).decode("utf-8")
file_bytes_np = np.asarray(bytearray(active_bytes), dtype=np.uint8)
img_rgb = cv2.cvtColor(cv2.imdecode(file_bytes_np, cv2.IMREAD_COLOR), cv2.COLOR_BGR2RGB)
cached_analysis = active_img["analysis"]
cached_params = active_img["params"]
# -------------------------------------------------------------
# HIGH-VISIBILITY AI HANDICRAFT ALERT / WARNING
# -------------------------------------------------------------
if cached_analysis and isinstance(cached_analysis, dict):
is_handicraft = cached_analysis.get("is_handicraft", True)
if not is_handicraft:
st.error(
"🚨 **Invalid Image Alert: Non-Handicraft Detected!**\n\n"
"Our AI Diagnostic Engine analyzed this image and classified it as **NOT a handicraft, drawing, sketch, or craft pattern**.\n\n"
"Tracing models and configurations are optimized specifically for artwork outlines. Please upload a valid image for correct results."
)
# -------------------------------------------------------------
# THREE-TAB WORKSPACE INTERFACE LAYOUT
# -------------------------------------------------------------
tab_analysis, tab_workspace, tab_advisor = st.tabs([
"📊 AI Diagnostic Analysis",
"🎨 Control Workspace",
"💡 Interactive AI Advisor & Staging Pro"
])
# --- TAB 1: STRICT AI DIAGNOSTIC ANALYSIS ---
with tab_analysis:
st.subheader(f"🛡️ Strict AI Target Quality Verification: `{active_img['name']}`")
if cached_analysis and isinstance(cached_analysis, dict):
c_score, c_suit = st.columns([1, 2])
with c_score:
score = cached_analysis.get("suitability_score", 0)
st.metric(label="Traceability Rating (Strict Audit)", value=f"{score}/100")
with c_suit:
if cached_analysis.get("suitable", True):
st.success("##### 👍 Acceptable Quality\nThe file passes edge-contrast guidelines and is suitable for trace conversion.")
else:
st.warning("##### ⚠️ Strict Review Advisory\nHigh ambient noise, shadows, or clutter detected. Expect some artifacts.")
with st.expander("👁️ View Full Strict Structural Diagnostics Breakdown", expanded=True):
col_det1, col_det2 = st.columns(2)
with col_det1:
st.markdown("**💡 Illumination & Shading Profile**")
st.write(cached_analysis.get("lighting_critique", "N/A"))
st.markdown("**📐 Geometric Foreground/Clutter Separation**")
st.write(cached_analysis.get("contrast_critique", "N/A"))
with col_det2:
st.markdown("**🔍 Edge Resolution & Clarity**")
st.write(cached_analysis.get("detail_clarity_critique", "N/A"))
st.markdown("**🔮 Vectorization Fidelity Forecast**")
st.write(cached_analysis.get("line_art_prediction", "N/A"))
# Offer one-click baseline ingestion
if cached_params:
st.write("")
st.button(
"⚙️ Sync Diagnostic Parameters to Slider Configurations",
on_click=load_recommended_settings,
args=(cached_params,),
use_container_width=True
)
else:
st.info("No active diagnostic metrics generated. Please make sure the uploaded image is evaluated properly.")
# --- TAB 2: ARTWORK FILTERING & CONTROL WORKSPACE ---
with tab_workspace:
# Setup dynamic processing variables inside OpenCV pipeline memory maps
h, w = img_rgb.shape[:2]
# Only calculate mathematical visual conversions if explicitly requested by the button matrix
if st.session_state.pipeline_triggered:
# --- GRABCUT BACKGROUND SEPARATION ---
if st.session_state.get("enable_grabcut", False):
with st.spinner("Isolating foreground object..."):
bbox_padding = st.session_state.get("bbox_padding", 10)
gc_mask = np.zeros((h, w), np.uint8)
bgdModel = np.zeros((1, 65), np.float64)
fgdModel = np.zeros((1, 65), np.float64)
rect = (bbox_padding, bbox_padding, w - (2 * bbox_padding), h - (2 * bbox_padding))
cv2.grabCut(img_rgb, gc_mask, rect, bgdModel, fgdModel, 5, cv2.GC_INIT_WITH_RECT)
binary_mask = np.where((gc_mask == 2) | (gc_mask == 0), 0, 1).astype("uint8")
img_rgb = img_rgb * binary_mask[:, :, np.newaxis]
# --- RESIZING & GRAYSCALE CONVERSION ---
img_large = cv2.resize(img_rgb, (int(w * st.session_state.scale_val), int(h * st.session_state.scale_val)), interpolation=cv2.INTER_CUBIC)
gray = cv2.cvtColor(img_large, cv2.COLOR_RGB2GRAY)
# --- SHARPENING / DE-BLUR ---
if st.session_state.sharpen_val > 0:
blurred_temp = cv2.GaussianBlur(gray, (0, 0), 3.0)
gray = cv2.addWeighted(gray, 1.0 + st.session_state.sharpen_val, blurred_temp, -st.session_state.sharpen_val, 0)
gray = np.clip(gray, 0, 255).astype(np.uint8)
# --- SMOOTHING & CANNY EDGES ---
gray = cv2.bilateralFilter(gray, 9, 75, 75)
edges = cv2.Canny(gray, st.session_state.canny_low_val, st.session_state.canny_high_val)
# --- MORPHOLOGICAL CLOSING (Fills minor gaps in lines) ---
kernel_close = np.ones((3, 3), np.uint8)
edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel_close, iterations=1)
# --- CONTOUR THICKNESS CONSTRAINTS ---
if (not st.session_state.skeletonize_val and st.session_state.contour_thickness_val > 1):
contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
edges_clean = np.zeros_like(edges)
cv2.drawContours(edges_clean, contours, -1, 255, st.session_state.contour_thickness_val)
edges = edges_clean
if st.session_state.skeletonize_val:
edges = skeletonize_image(edges)
elif st.session_state.dilate_iter_val > 0:
edges = cv2.dilate(edges, np.ones((2, 2), np.uint8), iterations=st.session_state.dilate_iter_val)
# --- PREPARE BUFFERS FOR DISPLAY AND VECTORIZATION ---
_, edge_buffer = cv2.imencode(".png", edges)
st.session_state.line_art_bytes = edge_buffer.tobytes()
else:
# Default clean array state placeholder before generation trigger
edges = np.zeros((h, w), dtype=np.uint8)
# --- RENDER COLUMNS FOR SIDE-BY-SIDE MATCHING ---
c1, c2, c3 = st.columns(3)
with c1:
st.subheader("Original Image")
st.image(img_rgb, use_container_width=True)
with c2:
st.subheader("Line Art")
if st.session_state.pipeline_triggered:
st.image(edges, use_container_width=True, caption="Computed edge trace map state.")
else:
st.info("Line art rendering process is queued. Click 'Process Image Filters' above to visualize vectorization paths.")
# Inline action button for generating Line Art
if st.button("🚀 Process Line Art", type="primary", use_container_width=False):
st.session_state.pipeline_triggered = True
st.rerun()
with c3:
st.subheader("Vector Image")
if st.session_state.get("svg_generated", False):
b64 = base64.b64encode(st.session_state.svg_bytes).decode()
st.markdown(f'<img src="data:image/svg+xml;base64,{b64}" width="100%"/>', unsafe_allow_html=True)
st.download_button(
label="📥 Download SVG",
data=st.session_state.svg_bytes,
file_name=f"primitive_vector_{active_img['name'].replace(' ', '_')}_{int(time.time())}.svg",
mime="image/svg+xml",
use_container_width=False,
)
else:
st.info("Awaiting geometric primitive resolution execution mapping steps.")
# Inline action button for SVG output generation
if st.session_state.pipeline_triggered:
if st.button("🏆 Generate SVG", type="secondary", use_container_width=False):
with st.spinner("Executing structural geometric primitive estimation..."):
st.session_state.svg_bytes = generate_svg(edges, st.session_state.skeletonize_val)
st.session_state.svg_generated = True
st.rerun()
# Post-Vectorization Refinement Routine Block
if st.session_state.get("svg_generated", False):
st.markdown("---")
st.markdown("### 🔍 Advanced Post-Vectorization Refinement")
st.write("Use Gemini Vision to analyze structural precision gaps between original artwork layout and vector output curves.")
if st.button("🔍 Analyze Generated SVG for Improvements", use_container_width=True):
with st.spinner("Executing side-by-side visual analysis..."):
refine_result = refine_svg_with_gemini(
active_bytes,
active_mime,
st.session_state.line_art_bytes,
)
st.session_state.ai_refinement = refine_result
if isinstance(refine_result, dict) and "recommended_parameters" in refine_result:
st.session_state.ai_refined_params = refine_result["recommended_parameters"]
else:
st.session_state.ai_refined_params = None
st.rerun()
if st.session_state.get("ai_refinement", None):
ref = st.session_state.ai_refinement
if isinstance(ref, dict) and "error" in ref:
st.error(ref["error"])
elif isinstance(ref, dict):
if ref.get("is_perfect", False) or st.session_state.ai_refined_params is None:
st.balloons()
st.success("✨ **Perfect Vectorization Reached!**\n\nThe vector mapping looks pristine and fully optimized!")
else:
st.info("📋 **AI Vector Alignment Review**")
col_ref1, col_ref2 = st.columns(2)
with col_ref1:
st.markdown("##### 📐 Line Continuity & Fidelity")
st.write(ref.get("edge_fidelity_critique", "No structural flaws found."))
with col_ref2:
st.markdown("##### 🧼 Noise & Background Mud")
st.write(ref.get("noise_clutter_critique", "No background clutter found."))
st.markdown("##### 🛠️ Correction Roadmap")
st.write(ref.get("the_fix", "Ready to optimize."))
p = st.session_state.ai_refined_params
if p:
st.write("---")
p_cols = st.columns(4)
p_cols[0].metric("Target Sharpen", f"{p.get('sharpen_val', 0.0)}")
p_cols[1].metric("Target Scale", f"{p.get('scale_val', 2.0)}x")
p_cols[2].metric("Target Canny", f"{p.get('canny_low_val', 10)}-{p.get('canny_high_val', 70)}")
p_cols[3].metric("Skeletonize", "On" if p.get('skeletonize_val', False) else "Off")
st.button(
"🚀 Apply Refined Settings",
type="primary",
use_container_width=True,
on_click=load_refined_settings,
args=(p,),
)
# --- TAB 3: INTERACTIVE AI ADVISOR & STAGING PRO ---
with tab_advisor:
st.subheader("💡 Interactive Quality Advisor & Staging Consultant")
st.write("Is your raw photo struggling to convert into a clean SVG? Let's dynamically diagnose your handicraft image and design a perfect prompt for ChatGPT/DALL-E to generate a clean outline.")
st.markdown("---")
st.markdown("##### 🔍 Describe Your Preferences & Image Concerns")
# Question 1: Common alternative photo questionnaire
has_alt_photos = st.radio(
"1. Do you have any alternative photos of this same handicraft?",
["No, this is my only photo", "Yes, I have alternative photos taken under different settings"],
index=0,
help="Uploading multiple variants allows our AI model to automatically isolate and select the highest contrast asset."
)
# Question 2: Free-form outline style description
preferred_style = st.text_input(
"2. Describe your preferred outline style (e.g., bold and thick stencils, fine-line detailed sketches, simple clean cartoon outlines):",
value="Crisp black and white vector outline with solid uniform lines and no inner details",
help="Type exactly how you want the generated outlines to look."
)
# Question 3: Free-form corrections / opinion input
correction_priority = st.text_area(
"3. Describe any specific concerns or trace corrections you want the model to resolve:",
value="Please completely remove the noisy textured background, connect any faint/broken borders, and ignore all shadows and gray tones.",
help="Let us know what parts of the image need adjusting or cleaning."
)
st.markdown("---")
# Make advisor key unique to the active state inputs to prevent cache collision
advisor_key = f"adv_{active_img['name']}_{has_alt_photos[:5]}_{preferred_style[:10]}_{correction_priority[:10]}"
if st.session_state.get("current_advisor_key") != advisor_key:
st.session_state.advisor_insights = None
if st.button("✨ Generate Custom ChatGPT Prompt & Staging Advice", type="primary", use_container_width=True):
st.session_state.current_advisor_key = advisor_key
with st.spinner("AI Studio analyzing visual diagnostics and custom preferences..."):
from services.gemini_service import prepare_gemini_env, rotator
prepare_gemini_env()
score = 100
clutter_note = "Minimal clutter detected."
lighting_note = "Uniform illumination."
if cached_analysis and isinstance(cached_analysis, dict):
score = cached_analysis.get("suitability_score", 75)
clutter_note = cached_analysis.get("contrast_critique", "N/A")
lighting_note = cached_analysis.get("lighting_critique", "N/A")
system_instruction = (
"You are an expert handicraft design consultant, product photographer, and vector design specialist. "
"You analyze physical image quality constraints and help users generate perfect digital templates using generative AI tools."
)
structured_prompt = f"""
The user uploaded a handicraft image named '{active_img['name']}'.
Image Analysis:
- Suitability Score: {score}/100
- Contrast: {clutter_note}
- Lighting: {lighting_note}
User Preferences:
- Outline Style: {preferred_style}
- Corrections: {correction_priority}
- Alternative Photos: {has_alt_photos}
Generate a SHORT and SIMPLE markdown response.
Rules:
- Maximum 250 words total.
- Use short bullet points only.
- Do NOT write long paragraphs.
- Keep every bullet under 20 words.
- Use simple English.
- Focus only on useful information.
- Avoid repeating the user's preferences.
- Be concise.
Return exactly these sections:
### ✅ Image Diagnosis
• Overall quality (1 sentence)
• Biggest issue (1 bullet)
• Quick recommendation (1 bullet)
### 🎨 ChatGPT / DALL·E Prompt
Provide ONE copy-paste prompt inside a markdown code block.
### 📸 Better Photo Tips
Give ONLY 3 short bullets.
Keep everything easy to scan.
"""
try:
advisor_agent = Gemini(
id="gemini-2.5-flash",
system_prompt=system_instruction
)
from agno.models.message import Message
response_object = advisor_agent.response([Message(role="user", content=structured_prompt)])
st.session_state.advisor_insights = response_object.content if hasattr(response_object, 'content') else str(response_object)
except Exception as chat_error:
rotator.rotate()
st.session_state.advisor_insights = f"⚠️ An anomaly occurred while synthesizing recommendations: {str(chat_error)}. Please try again."
st.rerun()
if st.session_state.get("advisor_insights"):
st.markdown("---")
st.markdown("### 🔮 Your Dynamic AI Advisor Blueprint")
st.markdown(st.session_state.advisor_insights)
# Render navigation context assistant if alternative images are active
if has_alt_photos == "Yes, I have alternative photos taken under different settings":
st.info(
"💡 **Quick Navigation:** Drag and drop your alternate photos into the file uploader at the very top. "
"The comparison board will automatically run diagnostics and show you which is the cleanest candidate."
)
else:
st.info("💡 Fill out your custom preferences above and click the button to generate a personalized ChatGPT prompt and physical staging recipes.")
else:
st.info("✨ Please upload one or more alternative photographs, drawings, or PDF documents containing your handicraft designs to begin!")
if not PDF_SUPPORT_AVAILABLE:
st.warning("ℹ️ **Unlock PDF Support:** Install PyMuPDF (`pip install pymupdf`) to extract pages from PDF files directly in this application.")
# =============================================================
# NEW PAGE 2: TOOL EXTRACT (OPENCV GRABCUT & CLIPBOARD)
# =============================================================
elif app_page == "🛠️ Tool Extract":
st.markdown("# 🛠️ Handicraft Tool Isolator & Extractor")
st.markdown("Upload pictures or PDFs of your craft items to isolate tools using standard Computer Vision (GrabCut), convert them into a transparent GIF asset, and copy them directly to your clipboard.")
# PDF Processing Configuration for extraction
if PDF_SUPPORT_AVAILABLE:
st.subheader("📄 PDF Processing Mode")
tool_pdf_mode = st.radio(
"Select document extraction method:",
["Extract Embedded Images", "Render Entire Pages"],
key="tool_pdf_mode_choice"
)
# Unified File Input Formats
tool_formats = ["png", "jpg", "jpeg"]
if PDF_SUPPORT_AVAILABLE:
tool_formats.append("pdf")
tool_help = "Upload a JPG, PNG, or multi-page PDF document containing tools."
else:
tool_help = "Upload JPG/PNG image. Install 'pymupdf' to unpack PDF items."
tool_file = st.file_uploader(tool_help, type=tool_formats, key="tool_extractor_uploader")
if tool_file:
t_images = []
if tool_file.name.lower().endswith(".pdf") and PDF_SUPPORT_AVAILABLE:
with st.spinner("Deconstructing pages..."):
t_pdf_data = tool_file.read()
t_images = process_pdf(t_pdf_data, tool_pdf_mode)
else:
t_bytes = tool_file.read()
t_mime = "image/png" if tool_file.type == "image/png" else "image/jpeg"
t_images = [{"name": tool_file.name, "bytes": t_bytes, "mime_type": t_mime}]
# Multiple elements selection handler
if len(t_images) > 1:
selected_tool_img = st.selectbox(
"Target design catalog index:",
options=[ti["name"] for ti in t_images],
key="tool_img_selector"
)
active_idx = [ti["name"] for ti in t_images].index(selected_tool_img)
else:
active_idx = 0
target_tool = t_images[active_idx]
# --- GrabCut Custom Parameters ---
st.sidebar.markdown("### 🎛️ Isolation Fine-Tuning")
bbox_padding = st.sidebar.slider(
"Bounding Box Padding",
min_value=2,
max_value=100,
value=15,
help="Tells the computer how close the tool is to the edges of the image. Lower values mean the tool spans nearly the whole frame."
)
iter_count = st.sidebar.slider("Extraction Accuracy Iterations", min_value=1, max_value=10, value=5)
# Draw split presentation boards
tc1, tc2 = st.columns(2)
with tc1:
st.subheader("📸 Original Input")
st.image(target_tool["bytes"], use_container_width=True)
with tc2:
st.subheader("✨ Isolated Tool Output")
with st.spinner("Executing CV GrabCut background separation..."):
try:
from PIL import Image
import io
# Convert uploaded bytes to OpenCV image format
raw_np = np.asarray(bytearray(target_tool["bytes"]), dtype=np.uint8)
decoded_img = cv2.imdecode(raw_np, cv2.IMREAD_COLOR)
h, w = decoded_img.shape[:2]
# Convert to RGB for PIL compatibility later
img_rgb = cv2.cvtColor(decoded_img, cv2.COLOR_BGR2RGB)
# Initialize mask matrices for GrabCut
mask = np.zeros((h, w), np.uint8)
bgdModel = np.zeros((1, 65), np.float64)
fgdModel = np.zeros((1, 65), np.float64)
# Define a rectangle wrapping the foreground tool based on user padding
rect = (bbox_padding, bbox_padding, w - (2 * bbox_padding), h - (2 * bbox_padding))
# Execute GrabCut directly using OpenCV
cv2.grabCut(img_rgb, mask, rect, bgdModel, fgdModel, iter_count, cv2.GC_INIT_WITH_RECT)
# Generate a clean binary mask where background = 0, foreground = 1
# (cv2.GC_PR_FGD and cv2.GC_FGD represent probable and definite foreground)
binary_mask = np.where((mask == 2) | (mask == 0), 0, 1).astype("uint8")
# Apply the mask to isolate the image content
isolated_rgb = img_rgb * binary_mask[:, :, np.newaxis]
# Create an alpha channel (transparency map) based on the mask
alpha_channel = (binary_mask * 255).astype("uint8")
# Stack them together to make a clean 4-channel transparent RGBA image
r, g, b = cv2.split(isolated_rgb)
rgba_mat = cv2.merge([r, g, b, alpha_channel])
# Convert directly to a transparent PIL GIF asset
pil_img = Image.fromarray(rgba_mat)
gif_io = io.BytesIO()
pil_img.save(gif_io, format="GIF", save_all=True, transparency=0, disposal=2)
final_bytes = gif_io.getvalue()
# Encode asset to Base64 for the browser clipboard handling injection
b64_output = base64.b64encode(final_bytes).decode("utf-8")
st.image(final_bytes, use_container_width=True, caption="Isolated tool asset ready")
except Exception as ex:
st.error(f"Failed processing background isolation details: {ex}")
final_bytes = None
if final_bytes:
st.markdown("---")
st.markdown("### 📋 Export & Distribution")
# JavaScript injection to bind image blob string straight inside clipboard framework
clipboard_html = f"""
<button id="clip-btn" style="
background-color: #FF4B4B;
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-weight: bold;
width: 100%;
font-size: 16px;">
📋 Copy Isolated Tool GIF to Clipboard
</button>
<script>
document.getElementById('clip-btn').addEventListener('click', async () => {{
try {{
const response = await fetch("data:image/gif;base64,{b64_output}");
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({{ [blob.type]: blob }})
]);
alert("Isolated tool GIF graphic copied successfully to your clipboard!");
}} catch (err) {{
console.error(err);
alert("Could not automatically copy image. Verify site security permissions or use Download button.");
}}
}});
</script>
"""
st.components.v1.html(clipboard_html, height=60)
# Standard physical download safety fallback
st.download_button(
label="📥 Save Transparent GIF Asset Instead",
data=final_bytes,
file_name=f"isolated_{target_tool['name'].rsplit('.', 1)[0]}.gif",
mime="image/gif",
use_container_width=True,
key="tool_download_fallback"
)
elif app_page == "⚡ Interactive Node Simplifier":
st.markdown("# ⚡ Interactive Node Simplifier")
if st.session_state.svg_bytes:
svg_content = st.session_state.svg_bytes.decode('utf-8')
vector_editor_html = """<!DOCTYPE html>
<html>
<head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/paper.js/0.12.17/paper-full.min.js"></script>
<style>
body { margin: 0; background: #12151c; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; overflow: hidden; }
#canvas { width: 100vw; height: 100vh; display: block; cursor: crosshair; }
.controls {
position: absolute;
top: 15px;
left: 15px;
z-index: 10;
display: flex;
flex-wrap: wrap;
gap: 12px;
background: rgba(30, 34, 43, 0.95);
padding: 12px 16px;
border-radius: 8px;
border-left: 4px solid #ff4b4b;
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
align-items: center;
}
button {
padding: 8px 16px;
cursor: pointer;
border-radius: 5px;
border: none;
font-weight: bold;
transition: all 0.2s ease;
}
.btn-export { background: #2ecc71; color: white; }
.btn-export:hover { background: #27ae60; }
.btn-simplify { background: #ff4b4b; color: white; }
.btn-simplify:hover { background: #e04141; }
.slider-container {
display: flex;
align-items: center;
color: #a3a8b4;
font-size: 0.9rem;
gap: 8px;
}
input[type="range"] {
cursor: pointer;
accent-color: #ff4b4b;
}
.hud {
position: absolute;
top: 15px;
right: 15px;
z-index: 10;
background: rgba(30, 34, 43, 0.95);
border-radius: 8px;
padding: 12px 18px;
border-left: 4px solid #2ecc71;
color: white;
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
font-size: 0.85rem;
min-width: 220px;
}
.hud-row {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.hud-row:last-child {
margin-bottom: 0;
padding-top: 6px;
border-top: 1px dashed #3a3f4d;
}
.hud-label { color: #808495; text-transform: uppercase; font-size: 0.75rem; font-weight: bold; }
.hud-val { font-weight: bold; color: #ffffff; }
.hud-highlight { color: #2ecc71; font-weight: 800; font-size: 1rem; }
</style>
</head>
<body>
<div class="topbar">
<div class="controls">
<button class="btn-export" onclick="exportSVG()">Export Edited SVG</button>
<div style="width: 1px; height: 20px; background: #3a3f4d;"></div>
<div class="slider-container">
<label for="tolerance">Simplification Strength:</label>
<input id="tolerance" type="range" min="0.5" max="15.0" step="0.5" value="3.0" oninput="updateToleranceLabel(this.value)">
<span id="tolerance-val" style="color: #ff4b4b; font-weight: bold;">3.0</span>
</div>
<button class="btn-simplify" onclick="applySimplification()">Simplify Node Mesh ✨</button>
</div>
<div class="hud">
<div class="hud-row">
<span class="hud-label">Original Vector Nodes:</span>
<span id="orig-nodes" class="hud-val">-</span>
</div>
<div class="hud-row">
<span class="hud-label">Current Nodes:</span>
<span id="curr-nodes" class="hud-val">-</span>
</div>
<div class="hud-row">
<span class="hud-label">Nodes Eliminated:</span>
<span id="reduced-nodes" class="hud-highlight">-</span>
</div>
</div>
</div>
<canvas id="canvas" resize></canvas>
<script type="text/javascript">
const canvas = document.getElementById('canvas');
paper.setup('canvas');
const tool = new paper.Tool();
let selectedSegment = null;
let importedItem = null;
let originalBackup = null;
let largestPath = null;
let initialNodeCount = 0;
const svgData = `{{escaped_svg_content}}`;
const parser = new DOMParser();
const doc = parser.parseFromString(svgData, "image/svg+xml");
const svgElement = doc.documentElement;
svgElement.removeAttribute("width");
svgElement.removeAttribute("height");
function countTotalNodes(item) {
let count = 0;
if (item instanceof paper.Path) {
count += item.segments.length;
} else if (item.children) {
item.children.forEach(child => {
count += countTotalNodes(child);
});
}
return count;
}
function styleItems(obj) {
if (obj instanceof paper.Path) {
obj.strokeColor = '#3498db';
if (!obj.strokeWidth || obj.strokeWidth === 0)
obj.strokeWidth = 2.5;
obj.fullySelected = false;
}
if (obj.children) {
obj.children.forEach(child => styleItems(child));
}
}
paper.project.importSVG(svgElement, function(item) {
if (!item) {
console.error("SVG load error.");
return;
}
importedItem = item;
findLargestPath(importedItem);
originalBackup = item.clone({ insert: false });
const targetBounds = paper.view.bounds.clone().scale(0.85);
importedItem.fitBounds(targetBounds);
importedItem.position = new paper.Point(
paper.view.center.x,
paper.view.center.y + 80
);
styleItems(importedItem);
initialNodeCount = countTotalNodes(importedItem);
document.getElementById('orig-nodes').innerText = initialNodeCount;
updateDiagnostics();
paper.view.update();
});
function findLargestPath(item) {
let largest = null;
let largestArea = 0;
function traverse(obj) {
if (obj instanceof paper.Path) {
const area = obj.bounds.width * obj.bounds.height;
if (area > largestArea) {
largestArea = area;
largest = obj;
}
}
if (obj.children) {
obj.children.forEach(traverse);
}
}
traverse(item);
if (largest) {
largest.data.keepOriginal = true;
}
}
function updateToleranceLabel(val) {
document.getElementById('tolerance-val').innerText = val;
}
function updateDiagnostics() {
if (!importedItem) return;
const currentCount = countTotalNodes(importedItem);
const reduced = initialNodeCount - currentCount;
const percentage = initialNodeCount > 0 ? ((reduced / initialNodeCount) * 100).toFixed(1) : 0;
document.getElementById('curr-nodes').innerText = currentCount;
document.getElementById('reduced-nodes').innerText = reduced + ' (' + percentage + '%)';
}
window.applySimplification = function() {
if (!importedItem || !originalBackup) return;
importedItem.remove();
importedItem = originalBackup.clone();
paper.project.activeLayer.addChild(importedItem);
importedItem.fitBounds(
paper.view.bounds.clone().scale(0.85)
);
importedItem.position = paper.view.center;
const tolerance = parseFloat(
document.getElementById('tolerance').value
);
function simplifyPathElements(obj) {
if (obj instanceof paper.Path) {
const wRatio =
obj.bounds.width /
importedItem.bounds.width;
const hRatio =
obj.bounds.height /
importedItem.bounds.height;
const isOuterFrame =
wRatio > 0.85 &&
hRatio > 0.85;
if (!obj.data.keepOriginal && !isOuterFrame) {
obj.simplify(tolerance);
}
obj.fullySelected = false;
}
if (obj.children) {
obj.children.forEach(child =>
simplifyPathElements(child)
);
}
}
simplifyPathElements(importedItem);
styleItems(importedItem);
updateDiagnostics();
paper.view.update();
};
tool.onMouseDown = function(event) {
selectedSegment = null;
const hitResult = paper.project.hitTest(event.point, {
segments: true,
tolerance: 15
});
if (hitResult && hitResult.segment) {
selectedSegment = hitResult.segment;
selectedSegment.selected = true;
}
};
tool.onMouseDrag = function(event) {
if (!selectedSegment) return;
selectedSegment.point = selectedSegment.point.add(event.delta);
updateDiagnostics();
paper.view.update();
};
tool.onMouseUp = function() {
if (selectedSegment) {
selectedSegment.selected = false;
}
selectedSegment = null;
};
window.exportSVG = function() {
const svgString = paper.project.exportSVG({
asString: true,
precision: 3
});
const blob = new Blob([svgString], { type: "image/svg+xml" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "handicraft_reduced_nodes.svg";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
window.addEventListener("resize", () => {
paper.view.viewSize =
new paper.Size(
canvas.clientWidth,
canvas.clientHeight
);
if (importedItem) {
importedItem.fitBounds(
paper.view.bounds.clone().scale(0.85)
);
importedItem.position = paper.view.center;
}
paper.view.update();
});
</script>
</body>
</html>""".replace("{{escaped_svg_content}}", svg_content.replace("\\", "\\\\").replace("`", "\\`"))
st.components.v1.html(vector_editor_html, height=750, scrolling=False)
else:
st.warning("Please process an image on the first page first.")