| """ |
| Agent 1 - Change Detection (Gradio version) |
| ------------------------------------------------ |
| Docker SDK requires a paid HF plan - this is the free-tier-compatible |
| rebuild using Gradio instead. Gradio Spaces still work perfectly as an API |
| for the gateway to call later: every Gradio app automatically exposes an |
| API endpoint (visible via the "Use via API" link at the bottom of the |
| deployed Space, and callable from Python with the `gradio_client` package) |
| in addition to the web UI - nothing about the gateway integration plan |
| actually changes, just how this Space is built. |
| |
| Gradio's File component with type="filepath" hands the underlying function |
| a local temp file path directly - exactly what run_agent1() already |
| expects, so no manual file-saving code is needed the way the FastAPI |
| version required. |
| |
| Run locally to test: python app.py |
| """ |
|
|
| import base64 |
| import os |
|
|
| import gradio as gr |
|
|
| from change_detection import run_agent1, ITEM_TYPE_ANGLES |
|
|
|
|
| def _encode_image_to_data_url(image_path): |
| """ |
| Reads an image file from this Space's own local disk and returns it as |
| a base64 Data URL, so the calling backend can use it directly with no |
| further processing - the crop_path field alone is useless to any |
| caller outside this Space, since it only points to a file on THIS |
| server's own filesystem. Format: "data:image/jpeg;base64,<data>", |
| which most HTTP clients / image libraries can consume directly. |
| """ |
| try: |
| with open(image_path, "rb") as f: |
| image_bytes = f.read() |
| encoded = base64.b64encode(image_bytes).decode("utf-8") |
| ext = os.path.splitext(image_path)[1].lower() |
| mime_type = "image/png" if ext == ".png" else "image/jpeg" |
| return f"data:{mime_type};base64,{encoded}" |
| except Exception: |
| return None |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| import spaces |
| gpu_decorator = spaces.GPU |
| except ImportError: |
| def gpu_decorator(func): |
| return func |
|
|
|
|
| @gpu_decorator |
| def process(item_id, item_type, baseline_files, return_files, complaint_text): |
| """ |
| Core logic, kept as a plain function so it's testable independently of |
| Gradio's UI wiring. |
| |
| FIX: no longer takes a manually-typed angles JSON string - the required |
| angles are already fully determined by item_type via ITEM_TYPE_ANGLES, |
| so asking the user to also type them was both redundant and fragile |
| (found via real testing: users naturally type things like "lid_exterior, |
| screen_keyboard" without brackets/quotes, which isn't valid JSON and |
| just produces a confusing error). Angles are now derived automatically |
| and shown to the user via show_required_angles() below, instead of |
| being something they have to get exactly right by hand. |
| """ |
| if not item_type or item_type not in ITEM_TYPE_ANGLES: |
| return {"error": f"Unknown item_type '{item_type}'. Known types: {list(ITEM_TYPE_ANGLES.keys())}"} |
|
|
| angle_list = ITEM_TYPE_ANGLES[item_type] |
|
|
| baseline_files = baseline_files or [] |
| return_files = return_files or [] |
|
|
| if len(baseline_files) != len(angle_list) or len(return_files) != len(angle_list): |
| return {"error": f"item_type '{item_type}' requires {len(angle_list)} angle(s), in this exact order: " |
| f"{angle_list}. Got {len(baseline_files)} baseline file(s) and {len(return_files)} " |
| f"return file(s) - please upload exactly one baseline and one return photo per " |
| f"angle, in the order shown above the upload boxes."} |
|
|
| baseline_photos = [{"angle": angle, "path": f.name if hasattr(f, "name") else f} |
| for angle, f in zip(angle_list, baseline_files)] |
| return_photos = [{"angle": angle, "path": f.name if hasattr(f, "name") else f} |
| for angle, f in zip(angle_list, return_files)] |
|
|
| try: |
| result = run_agent1( |
| item_id=item_id or "unnamed_item", item_type=item_type, |
| baseline_photos=baseline_photos, return_photos=return_photos, |
| complaint_text=complaint_text or None, crop_output_dir="agent1_api_crops", |
| ) |
|
|
| |
| |
| |
| |
| |
| for angle_result in result.get("angles", []): |
| for region in angle_result.get("regions", []): |
| crop_path = region.get("crop_path") |
| if crop_path and os.path.isfile(crop_path): |
| region["crop_base64"] = _encode_image_to_data_url(crop_path) |
|
|
| return result |
| except Exception as e: |
| return {"error": f"Agent 1 processing failed: {e}"} |
|
|
|
|
| def show_required_angles(item_type): |
| """Updates a read-only label the moment the item type is picked, so the |
| user knows exactly what order to upload photos in - replaces having to |
| type that information themselves.""" |
| if not item_type or item_type not in ITEM_TYPE_ANGLES: |
| return "Pick an item type to see which angles are required." |
| angles = ITEM_TYPE_ANGLES[item_type] |
| return (f"This item type requires {len(angles)} angle(s), in this exact order: " |
| f"**{' -> '.join(angles)}**. Upload one baseline and one return photo per angle below, " |
| f"in that order.") |
|
|
|
|
| with gr.Blocks(title="Agent 1 - Change Detection") as demo: |
| gr.Markdown("# Agent 1 - Change Detection\n" |
| "Detects whether a returned item's condition changed since pickup. " |
| "No API key required - classical computer vision only.") |
|
|
| with gr.Row(): |
| item_id_input = gr.Textbox(label="Item ID", value="test_item") |
| |
| |
| |
| |
| item_type_input = gr.Dropdown(label="Item Type", choices=list(ITEM_TYPE_ANGLES.keys()), allow_custom_value=True) |
|
|
| angles_display = gr.Markdown("Pick an item type to see which angles are required.") |
| item_type_input.change(fn=show_required_angles, inputs=item_type_input, outputs=angles_display) |
|
|
| with gr.Row(): |
| baseline_input = gr.File(label="Baseline (pickup) photos, in the order shown above", file_count="multiple", type="filepath") |
| return_input = gr.File(label="Return photos, in the same order", file_count="multiple", type="filepath") |
|
|
| complaint_input = gr.Textbox(label="Complaint text (optional)") |
|
|
| with gr.Row(): |
| submit_btn = gr.Button("Run Detection", variant="primary") |
| clear_btn = gr.ClearButton( |
| value="Clear All (start a fresh test)", |
| components=[item_id_input, item_type_input, baseline_input, return_input, complaint_input, angles_display], |
| ) |
| output = gr.JSON(label="Result") |
| clear_btn.add(output) |
|
|
| |
| |
| |
| |
| |
| |
|
|
| submit_btn.click( |
| fn=process, |
| inputs=[item_id_input, item_type_input, baseline_input, return_input, complaint_input], |
| outputs=output, |
| api_name="detect", |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|