Build a Vehicle Damage Assessment App

Use Google Gemini Vision to analyze car damage photos and return structured reports.

Introduction

30 Second Summary

When a car gets into an accident, insurance companies need to figure out exactly what is damaged, how bad it is, and what it will cost to fix. Today, that process often means waiting days for a human assessor to show up and write a report.

In this project, you will build a web application that uses Google Gemini's vision AI to instantly analyze photos of damaged vehicles. Upload a photo, and the app returns a structured damage report identifying each damaged part, severity level, and recommended repair action.

What You'll Build

A vehicle damage assessment web app powered by Google Gemini's multimodal API, with a Flask backend and a drag-and-drop frontend that displays color-coded damage reports.

By the end of this project, you'll have:

  • A working Flask web app that accepts vehicle damage photos and returns AI-powered assessments through a clean interface.
  • A structured JSON damage report schema using Pydantic that classifies damage by part, type, severity, and repair category.
  • Hands-on experience integrating Gemini's vision API with a Python backend using prompt engineering for structured output.
  • Secret Mission: Multi-image support so users can upload photos from multiple angles for a more comprehensive assessment.

Are there any prerequisites?

This project assumes basic familiarity with Python and APIs. You do not need any prior experience with AI, computer vision, or Flask.

Before We Start

Before We Start...

Set Up Your Project and Get a Gemini API Key

Before you can build the damage assessment app, you need a working Python environment and access to Google's Gemini vision API.

In this step, you will create a project folder, install the Python packages your app needs, and grab a free API key from Google AI Studio. You will also set up the file structure so everything is ready for coding in the next step.

In this step, get ready to:
  • Create a project folder with a Python virtual environment and install packages.
  • Get a free Gemini API key and store it in a .env file.
  • Create the project file structure for your Flask app.
Create your project folder and install packages

A virtual environment keeps your project's packages isolated from other Python projects on your machine. You will create one now, activate it, then install the packages your app needs.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir vehicle-damage-app
cd vehicle-damage-app
  • Confirm the folder exists by running:
ls

You should see an empty listing since the folder is brand new.

  • Create a Python virtual environment by running this command:
python3 -m venv venv

What does this command do?

This creates a folder called venv inside your project. It contains a private copy of Python and a place to install packages without affecting the rest of your system.

  • Activate the virtual environment by running the appropriate command for your operating system:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned then try the activate command again.

On macOS, make sure you typed source before the path. Without it, the command will not work.

help me activate my Python virtual environment

  • Install the required packages by running this command:
pip install flask google-genai python-dotenv

What are these packages?

  • flask is the web framework that powers your app's backend and routes.
  • google-genai is Google's official SDK for calling the Gemini API. It also installs Pydantic as a dependency, which you will use for structured output.
  • python-dotenv loads environment variables from a .env file so you never hardcode secrets.

The install takes a moment. When it finishes, you should see Successfully installed followed by a list of package names.

Seeing a permission error or install failure?

Make sure you see (venv) in your terminal prompt. If it is missing, the virtual environment is not active and pip is trying to install globally.

If you see a network error, check your internet connection and try the command again.

help me fix my pip install error

Get a free Gemini API key

Your app needs an API key to authenticate with Google's Gemini service. Google AI Studio provides free keys with generous rate limits, so this costs nothing.

  • Open your browser and go to aistudio.google.com.
  • Sign in with your Google account.
  • Click Get API Key in the left sidebar.
  • Click Create API Key.
  • If prompted to select a Google Cloud project, choose the default option or create a new one.
  • Copy the generated API key. You will use it in the next step.

Why is this free?

Gemini's free tier provides free input and output tokens for select models, including Gemini 2.5 Flash. No credit card is required. Rate limits apply per project, but they are more than enough for development and testing.

Now store the key safely in your project so your Python code can access it.

  • Back in your terminal, create a .env file inside your vehicle-damage-app folder by running:

macOS

touch .env

Windows

New-Item .env
  • Open .env in any text editor and add this line, replacing your-api-key-here with the key you just copied:
GEMINI_API_KEY=your-api-key-here

What does this file do?

The .env file stores sensitive values outside your code. The python-dotenv package reads this file at startup so your app can access the key without it ever being hardcoded.

  • Save .env.
  • Confirm the file exists by running:
ls -a

You should see .env and venv in the output.

Don't see .env in the listing?

Files starting with a dot are hidden by default. The -a flag in ls -a shows hidden files. On Windows PowerShell, use Get-ChildItem -Force instead.

help me find my .env file

Create the project file structure

Flask expects a specific folder layout. Templates (HTML files) go in a templates/ folder, and static assets (CSS, JavaScript, images) go in a static/ folder.

  • Create the folders and files by running these commands:
mkdir templates
mkdir static
touch app.py
touch templates/index.html
touch static/style.css

What is each file for?

  • app.py is your Flask backend. It will handle image uploads and call the Gemini API.
  • templates/index.html is the frontend page with the upload interface and damage report display.
  • static/style.css holds the styling to give the app a professional look.
  • Confirm the structure by running:
ls

You should see app.py, static, templates, and venv in the output.

Now open the project in Cursor so you are ready to write code.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the vehicle-damage-app folder, then click Open.

You should see app.py, .env, static/, and templates/ in the Cursor sidebar on the left.

Don't see .env in the sidebar?

Cursor may hide dotfiles by default. Click the gear icon in the file explorer panel and make sure hidden files are visible. The .env file is still there. You confirmed it with ls -a earlier.

help me see hidden files in Cursor

✔️ Awesome, I've got everything!

Great. Double check you have all four items in your sidebar and that your .env file contains your Gemini API key.

ⓧ I'd like to double check the full code

Your .env file should look like this (with your own key in place of the placeholder):

GEMINI_API_KEY=your-api-key-here

The files app.py, templates/index.html, and static/style.css should all exist but be empty. You will add code to them in the next steps.

Your environment is ready and your project structure is in place. Next up, you will build the backend that sends vehicle images to Gemini and gets back structured damage reports.

Build the Damage Assessment Backend

Your development environment is ready and your API key is in place. Now it's time to build the core of your app.

In this step, you'll write the entire Python backend that powers the vehicle damage assessment. This means defining exactly what a damage report looks like, connecting to Gemini's vision API, and creating endpoints that the frontend can call.

In this step, get ready to:
  • Define the Pydantic schema for structured damage reports.
  • Write the function that sends images to Gemini and returns validated results.
  • Create Flask routes to serve the app and handle image uploads.
Create the Pydantic damage report schema

When you ask an AI model to describe vehicle damage, it can return wildly different responses each time. One response might be a paragraph, another a bulleted list, another just a single sentence.

Pydantic models solve this by defining the exact shape of the data you want back. Gemini will use your schema as a contract, guaranteeing that every response matches the structure you defined.

  • Open app.py in Cursor and add the following imports and app configuration:
import os
import json
from flask import Flask, request, jsonify, render_template
from google import genai
from google.genai import types
from pydantic import BaseModel, Field
from typing import List, Optional
from dotenv import load_dotenv

load_dotenv()

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024  # 16MB max upload

client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))

SYSTEM_INSTRUCTION = """"""

What does this code do?

  • load_dotenv() reads your .env file so the API key is available as an environment variable.
  • Flask(__name__) creates your web app. The MAX_CONTENT_LENGTH config caps uploaded files at 16MB.
  • genai.Client() initializes the Gemini SDK using your API key from the environment.
  • SYSTEM_INSTRUCTION is an empty string for now. You'll fill this with the damage assessment prompt in the next step.
  • Save app.py.
  • You won't see a change yet. The next chunk adds the Pydantic models that give this setup its purpose.
  • Below the SYSTEM_INSTRUCTION line, add the two Pydantic models that define your damage report schema:
class DamageItem(BaseModel):
    part: str = Field(description="The vehicle part that is damaged")
    damage_type: str = Field(description="Type of damage: dent, scratch, crack, shatter, misalignment, paint_damage, rust, puncture")
    severity: str = Field(description="Severity level: minor, moderate, severe")
    repair_action: str = Field(description="Recommended action: repair, replace, inspect")
    confidence: float = Field(description="Confidence score between 0.0 and 1.0")


class DamageReport(BaseModel):
    vehicle_detected: bool = Field(description="Whether a vehicle was detected in the image")
    damages: List[DamageItem] = Field(description="List of identified damages")
    summary: str = Field(description="Brief plain-language summary of the overall damage")
    overall_severity: Optional[str] = Field(description="Overall severity: minor, moderate, severe, or none", default=None)

What does this code do?

These two classes define the exact JSON structure Gemini must follow when it responds.

  • DamageItem represents a single damaged area. Each item captures the vehicle part, type of damage, how severe it is, what repair is recommended, and how confident the AI is.
  • DamageReport is the top-level response. It includes a vehicle_detected flag, a list of DamageItem objects, a plain-language summary, and an overall severity rating.
  • The Field(description=...) annotations tell Gemini what each field means. The model reads these descriptions when deciding how to fill in the response.

Why Pydantic instead of raw JSON parsing?

Without a schema, you would need to parse Gemini's raw text response, guess where the JSON starts and ends, and manually validate every field. That process is fragile and breaks easily.

Pydantic models eliminate all of that. The Gemini SDK accepts a Pydantic class as a response_schema, which forces the model to return valid JSON matching your schema every time.

  • Save app.py.
  • You won't see output yet. The next chunk builds the function that actually uses these models.
Write the damage analysis function

Your schema defines the shape of the report. Now you need the function that actually sends an image to Gemini and gets a validated report back.

Before the main analysis function, you'll add a couple of helper functions that validate uploaded files. These keep your app secure by rejecting anything that isn't an image.

  • Below the DamageReport class, add the file validation constants and helpers:
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"}
MIME_TYPES = {
    "png": "image/png",
    "jpg": "image/jpeg",
    "jpeg": "image/jpeg",
    "webp": "image/webp",
}


def allowed_file(filename):
    return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS


def get_mime_type(filename):
    ext = filename.rsplit(".", 1)[1].lower()
    return MIME_TYPES.get(ext, "image/jpeg")

What does this code do?

  • ALLOWED_EXTENSIONS lists the image formats Gemini supports. Files with any other extension get rejected.
  • MIME_TYPES maps each extension to its MIME type string. Gemini needs this to understand the image format.
  • allowed_file() checks that the uploaded file has a valid image extension.
  • get_mime_type() extracts the extension and returns the matching MIME type.
  • Save app.py.
  • You won't see output yet. The next chunk builds the core analysis function that uses these helpers.
  • Below the get_mime_type function, add the analyze_damage function that sends an image to Gemini:
def analyze_damage(image_bytes, mime_type):
    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=[
            types.Part.from_bytes(data=image_bytes, mime_type=mime_type),
            "Analyze this vehicle image for damage. Provide a detailed damage assessment.",
        ],
        config=types.GenerateContentConfig(
            system_instruction=SYSTEM_INSTRUCTION,
            response_mime_type="application/json",
            response_schema=DamageReport,
        ),
    )
    report = DamageReport.model_validate_json(response.text)
    return report

What does this code do?

This is where your app talks to Gemini. The function takes raw image bytes and sends them alongside a text prompt to the gemini-2.5-flash model.

  • types.Part.from_bytes() converts raw image data into a format Gemini can read. The image goes first in the contents array because Gemini performs better when it sees the image before the text prompt.
  • response_mime_type="application/json" tells Gemini to respond in JSON format.
  • response_schema=DamageReport passes your Pydantic model so Gemini knows the exact structure to follow.
  • DamageReport.model_validate_json() parses Gemini's JSON response and validates it against your schema. If anything is malformed, Pydantic raises an error.
  • Save app.py.
  • You won't see output yet. The final chunk in this step adds the Flask routes that call this function.

Seeing a red underline on imports?

If Cursor shows import errors for google.genai or pydantic, make sure your virtual environment is activated. Check the bottom-left corner of Cursor for the active Python interpreter and confirm it points to your project's virtual environment.

Help me fix import errors in my Python virtual environment.

Create the Flask routes

Your analysis function is ready, but there's no way to reach it from a browser yet. Flask routes act as the entry points that connect incoming web requests to your Python functions.

  • Below the analyze_damage function, add the Flask routes and the main entry point:
@app.route("/")
def index():
    return render_template("index.html")


@app.route("/analyze", methods=["POST"])
def analyze():
    if "image" not in request.files:
        return jsonify({"error": "No image file provided"}), 400

    file = request.files["image"]

    if file.filename == "":
        return jsonify({"error": "No file selected"}), 400

    if not allowed_file(file.filename):
        return jsonify({"error": "File type not allowed. Use PNG, JPG, JPEG, or WEBP."}), 400

    try:
        image_bytes = file.read()
        mime_type = get_mime_type(file.filename)
        report = analyze_damage(image_bytes, mime_type)
        return jsonify(report.model_dump())
    except Exception as e:
        return jsonify({"error": f"Analysis failed: {str(e)}"}), 500


if __name__ == "__main__":
    app.run(debug=True, port=5000)

What does this code do?

  • The GET / route serves your HTML frontend page when someone visits the app in a browser.
  • The POST /analyze route is where the frontend sends uploaded images. It runs three validation checks (file present, filename not empty, extension allowed) before calling analyze_damage().
  • report.model_dump() converts the Pydantic object back into a plain dictionary so jsonify() can send it as a JSON response.
  • The try/except block catches any errors from the Gemini API and returns a clear error message instead of crashing.
  • The if __name__ == "__main__" block starts the Flask server on port 5000 with debug mode on.
  • Save app.py.

Your backend is fully written. You can't test it in the browser yet because the HTML frontend is still empty.

Seeing syntax errors in Cursor?

Double-check that you have matching parentheses on the generate_content call. The nested config=types.GenerateContentConfig(...) block has several closing parentheses that are easy to miss.

Help me fix a syntax error in my app.py file.

✔️ Awesome, I've got everything!

Great. Double check you've saved app.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete app.py file as it should look right now. Compare it with yours line by line.

import os
import json
from flask import Flask, request, jsonify, render_template
from google import genai
from google.genai import types
from pydantic import BaseModel, Field
from typing import List, Optional
from dotenv import load_dotenv

load_dotenv()

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024  # 16MB max upload

client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))

SYSTEM_INSTRUCTION = """"""


class DamageItem(BaseModel):
    part: str = Field(description="The vehicle part that is damaged")
    damage_type: str = Field(description="Type of damage: dent, scratch, crack, shatter, misalignment, paint_damage, rust, puncture")
    severity: str = Field(description="Severity level: minor, moderate, severe")
    repair_action: str = Field(description="Recommended action: repair, replace, inspect")
    confidence: float = Field(description="Confidence score between 0.0 and 1.0")


class DamageReport(BaseModel):
    vehicle_detected: bool = Field(description="Whether a vehicle was detected in the image")
    damages: List[DamageItem] = Field(description="List of identified damages")
    summary: str = Field(description="Brief plain-language summary of the overall damage")
    overall_severity: Optional[str] = Field(description="Overall severity: minor, moderate, severe, or none", default=None)


ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"}
MIME_TYPES = {
    "png": "image/png",
    "jpg": "image/jpeg",
    "jpeg": "image/jpeg",
    "webp": "image/webp",
}


def allowed_file(filename):
    return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS


def get_mime_type(filename):
    ext = filename.rsplit(".", 1)[1].lower()
    return MIME_TYPES.get(ext, "image/jpeg")


def analyze_damage(image_bytes, mime_type):
    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=[
            types.Part.from_bytes(data=image_bytes, mime_type=mime_type),
            "Analyze this vehicle image for damage. Provide a detailed damage assessment.",
        ],
        config=types.GenerateContentConfig(
            system_instruction=SYSTEM_INSTRUCTION,
            response_mime_type="application/json",
            response_schema=DamageReport,
        ),
    )
    report = DamageReport.model_validate_json(response.text)
    return report


@app.route("/")
def index():
    return render_template("index.html")


@app.route("/analyze", methods=["POST"])
def analyze():
    if "image" not in request.files:
        return jsonify({"error": "No image file provided"}), 400

    file = request.files["image"]

    if file.filename == "":
        return jsonify({"error": "No file selected"}), 400

    if not allowed_file(file.filename):
        return jsonify({"error": "File type not allowed. Use PNG, JPG, JPEG, or WEBP."}), 400

    try:
        image_bytes = file.read()
        mime_type = get_mime_type(file.filename)
        report = analyze_damage(image_bytes, mime_type)
        return jsonify(report.model_dump())
    except Exception as e:
        return jsonify({"error": f"Analysis failed: {str(e)}"}), 500


if __name__ == "__main__":
    app.run(debug=True, port=5000)

Your entire backend is in place. Next up, you'll craft the system prompt that tells Gemini exactly how to assess vehicle damage.

Design the Vehicle Damage Report Prompt

Your backend is fully wired up with routes, schema models, and the Gemini API call. The one missing piece is the actual intelligence: the system instruction that tells Gemini how to assess vehicle damage.

A well-crafted prompt is the difference between vague AI output and a structured, professional damage report. In this step, you will write a detailed system instruction that guides Gemini to act like an insurance damage assessor, handle edge cases gracefully, and then test it with a real image.

In this step, get ready to:
  • Write a system instruction that tells Gemini exactly how to assess vehicle damage.
  • Handle edge cases like non-vehicle images and undamaged cars.
  • Test the prompt by running the app and uploading a sample damage photo.
Craft the system instruction for damage assessment

Right now, the SYSTEM_INSTRUCTION variable in app.py is an empty string. The analyze_damage function already passes this variable to Gemini as its system instruction, so filling it in is all you need to do.

What is a system instruction?

A system instruction is a behind-the-scenes prompt that shapes how an AI model behaves for every request. It sets the model's role, the format of its output, and the rules it follows.

In this project, the system instruction turns a general-purpose AI into a specialized vehicle damage assessor. The more specific the instructions, the more consistent and useful the output becomes.

  • In app.py, find the empty SYSTEM_INSTRUCTION variable:
SYSTEM_INSTRUCTION = """"""
  • Replace the entire line with the following system instruction:
SYSTEM_INSTRUCTION = """You are an expert vehicle damage assessor for the automotive insurance industry.
When given a photo of a vehicle, analyze it for any visible damage.

For each area of damage you identify:
1. Name the specific vehicle part affected (e.g., front bumper, driver door, hood, windshield, rear quarter panel, headlight, tail light, fender, roof, trunk lid, side mirror).
2. Classify the damage type as one of: dent, scratch, crack, shatter, misalignment, paint_damage, rust, puncture.
3. Rate the severity as: minor, moderate, or severe.
4. Recommend a repair action as: repair, replace, or inspect.
5. Provide a confidence score between 0.0 and 1.0 for your assessment of that damage.

Also provide:
- An overall severity rating for the entire vehicle.
- A brief plain-language summary of the damage (2-3 sentences).
- A boolean indicating whether a vehicle was detected in the image.

If the image does not contain a vehicle, set vehicle_detected to false, leave damages empty, and set summary to explain that no vehicle was found.
If a vehicle is present but no damage is visible, set vehicle_detected to true, leave damages empty, and note in the summary that no damage was detected."""

What does this prompt do?

This system instruction works because it gives Gemini extremely specific constraints rather than vague guidance.

  • The role statement ("expert vehicle damage assessor for the automotive insurance industry") anchors Gemini's language and expertise level.
  • The numbered list gives Gemini a repeatable process for each damaged area: identify part, classify type, rate severity, recommend action, score confidence.
  • Listing specific part names and damage types (like "paint_damage" and "misalignment") constrains Gemini to a fixed vocabulary that matches your Pydantic schema.
  • The "Also provide" section requests the summary fields that DamageReport expects.
  • Save app.py.

Seeing a syntax error?

Make sure the string opens with three double-quotes (""") and closes with three double-quotes on the last line. A missing quote will break the entire file.

help me fix a syntax error in my SYSTEM_INSTRUCTION string

Understand the edge case handling

Not every image a user uploads will be a damaged car. Your prompt needs to handle these situations gracefully instead of hallucinating damage that does not exist.

How does the prompt handle edge cases?

The last two lines of the system instruction cover two critical scenarios:

  • No vehicle in the image: Gemini sets vehicle_detected to false, returns an empty damages list, and explains in the summary that no vehicle was found. This prevents the model from inventing damage in a photo of a cat.
  • Vehicle present but undamaged: Gemini sets vehicle_detected to true, returns an empty damages list, and notes in the summary that no damage was detected. This prevents the model from hallucinating scratches on a pristine car.

The vehicle_detected boolean in your DamageReport schema is what makes this work. Your frontend can check this field and show an appropriate message instead of a blank report.

Why list specific vocabulary in the prompt?

Without explicit constraints, Gemini might return "small dent" or "Dent (minor)" or "dented" for the same observation. By listing exact values like "dent" and "minor" as separate fields, you guarantee the output stays machine-readable.

The Pydantic schema enforces the JSON structure, while the prompt constrains the vocabulary inside that structure. Both layers together give you reliable, consistent output.

Test the prompt with a sample image

Your backend is complete enough to test. You do not have a frontend yet, but you can send an image directly to the /analyze endpoint using curl in the terminal.

  • Search the web for "car damage photo" and save one image to your project folder. Name it something simple like test-damage.jpg.
  • Open a terminal in your project folder and start the Flask server by running:
python app.py

You should see output like * Running on http://127.0.0.1:5000 confirming the server is up.

  • Open a second terminal window in the same project folder.
  • Send your test image to the /analyze endpoint by running:
curl -X POST -F "image=@test-damage.jpg" http://localhost:5000/analyze

What does this command do?

  • curl -X POST sends an HTTP POST request.
  • -F "image=@test-damage.jpg" attaches the image file as form data with the field name image (which matches what your Flask route expects from request.files["image"]).
  • The @ symbol tells curl to read from a file on disk rather than sending the literal text.

After a few seconds, you should see a JSON response in your terminal. It will look something like this (with different values depending on your image):

{
  "vehicle_detected": true,
  "damages": [
    {
      "part": "front bumper",
      "damage_type": "dent",
      "severity": "moderate",
      "repair_action": "repair",
      "confidence": 0.85
    }
  ],
  "summary": "The vehicle has a moderate dent on the front bumper consistent with a low-speed collision.",
  "overall_severity": "moderate"
}
  • Verify that the response has these fields: vehicle_detected, damages (a list), summary, and overall_severity.
  • Each item in the damages list should have part, damage_type, severity, repair_action, and confidence.

Getting an error response?

  • If you see "error": "No image file provided", make sure the @ symbol is before your filename in the curl command.
  • If you see "error": "Analysis failed", check that your .env file has a valid GEMINI_API_KEY value (not the placeholder text).
  • If you see "error": "File type not allowed", your image must be a PNG, JPG, JPEG, or WEBP file.

help me debug my /analyze endpoint error

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py and your test returned valid JSON.

ⓧ I'd like to double check the full code

Here is the complete app.py as it should look at the end of this step:

import os
import json
from flask import Flask, request, jsonify, render_template
from google import genai
from google.genai import types
from pydantic import BaseModel, Field
from typing import List, Optional
from dotenv import load_dotenv

load_dotenv()

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024  # 16MB max upload

client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))

SYSTEM_INSTRUCTION = """You are an expert vehicle damage assessor for the automotive insurance industry.
When given a photo of a vehicle, analyze it for any visible damage.

For each area of damage you identify:
1. Name the specific vehicle part affected (e.g., front bumper, driver door, hood, windshield, rear quarter panel, headlight, tail light, fender, roof, trunk lid, side mirror).
2. Classify the damage type as one of: dent, scratch, crack, shatter, misalignment, paint_damage, rust, puncture.
3. Rate the severity as: minor, moderate, or severe.
4. Recommend a repair action as: repair, replace, or inspect.
5. Provide a confidence score between 0.0 and 1.0 for your assessment of that damage.

Also provide:
- An overall severity rating for the entire vehicle.
- A brief plain-language summary of the damage (2-3 sentences).
- A boolean indicating whether a vehicle was detected in the image.

If the image does not contain a vehicle, set vehicle_detected to false, leave damages empty, and set summary to explain that no vehicle was found.
If a vehicle is present but no damage is visible, set vehicle_detected to true, leave damages empty, and note in the summary that no damage was detected."""


class DamageItem(BaseModel):
    part: str = Field(description="The vehicle part that is damaged")
    damage_type: str = Field(description="Type of damage: dent, scratch, crack, shatter, misalignment, paint_damage, rust, puncture")
    severity: str = Field(description="Severity level: minor, moderate, severe")
    repair_action: str = Field(description="Recommended action: repair, replace, inspect")
    confidence: float = Field(description="Confidence score between 0.0 and 1.0")


class DamageReport(BaseModel):
    vehicle_detected: bool = Field(description="Whether a vehicle was detected in the image")
    damages: List[DamageItem] = Field(description="List of identified damages")
    summary: str = Field(description="Brief plain-language summary of the overall damage")
    overall_severity: Optional[str] = Field(description="Overall severity: minor, moderate, severe, or none", default=None)


ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"}
MIME_TYPES = {
    "png": "image/png",
    "jpg": "image/jpeg",
    "jpeg": "image/jpeg",
    "webp": "image/webp",
}


def allowed_file(filename):
    return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS


def get_mime_type(filename):
    ext = filename.rsplit(".", 1)[1].lower()
    return MIME_TYPES.get(ext, "image/jpeg")


def analyze_damage(image_bytes, mime_type):
    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=[
            types.Part.from_bytes(data=image_bytes, mime_type=mime_type),
            "Analyze this vehicle image for damage. Provide a detailed damage assessment.",
        ],
        config=types.GenerateContentConfig(
            system_instruction=SYSTEM_INSTRUCTION,
            response_mime_type="application/json",
            response_schema=DamageReport,
        ),
    )
    report = DamageReport.model_validate_json(response.text)
    return report


@app.route("/")
def index():
    return render_template("index.html")


@app.route("/analyze", methods=["POST"])
def analyze():
    if "image" not in request.files:
        return jsonify({"error": "No image file provided"}), 400

    file = request.files["image"]

    if file.filename == "":
        return jsonify({"error": "No file selected"}), 400

    if not allowed_file(file.filename):
        return jsonify({"error": "File type not allowed. Use PNG, JPG, JPEG, or WEBP."}), 400

    try:
        image_bytes = file.read()
        mime_type = get_mime_type(file.filename)
        report = analyze_damage(image_bytes, mime_type)
        return jsonify(report.model_dump())
    except Exception as e:
        return jsonify({"error": f"Analysis failed: {str(e)}"}), 500


if __name__ == "__main__":
    app.run(debug=True, port=5000)

Your AI damage assessor is working. Next up, you will build the frontend so users can drag and drop photos through a proper web interface instead of using terminal commands.

Build the Frontend Upload and Report Dashboard

Your backend can analyze vehicle damage photos and return structured JSON reports. Right now the only way to use it is through a tool like curl or Postman.

In this step, you will build a web interface that lets users drag and drop photos, send them to your /analyze endpoint, and see a color-coded damage report. The entire frontend lives in one HTML file with inline JavaScript.

In this step, get ready to:
  • Create the HTML page with a drag-and-drop upload zone and image preview.
  • Write JavaScript that handles file selection and sends images to your Flask API.
  • Build a report display that renders damage cards with color-coded severity badges.
Create the HTML page with a drag-and-drop upload zone

The page needs three main areas: an upload zone where users drop their image, a loading indicator while Gemini processes the photo, and a report section where the results appear.

  • Open templates/index.html in Cursor.
  • Paste the following HTML structure into the file:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vehicle Damage Assessor</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    <div class="container">
        <header>
            <h1>Vehicle Damage Assessor</h1>
            <p>Upload a photo of vehicle damage for AI-powered analysis</p>
        </header>

        <div class="upload-section">
            <!-- Drop zone for drag-and-drop file uploads -->
            <div id="dropZone" class="drop-zone">
                <div class="drop-zone-content">
                    <span class="drop-zone-icon">📸</span>
                    <p>Drag and drop a vehicle photo here</p>
                    <p class="drop-zone-hint">or click to browse</p>
                </div>
                <input type="file" id="fileInput" accept="image/png,image/jpeg,image/webp" hidden>
            </div>
            <!-- Image preview shown after file selection -->
            <div id="preview" class="preview" hidden>
                <img id="previewImage" alt="Uploaded vehicle photo">
                <button id="clearBtn" class="btn-clear">Clear</button>
            </div>
            <button id="analyzeBtn" class="btn-analyze" hidden>Analyze Damage</button>
        </div>

        <!-- Loading spinner shown during API call -->
        <div id="loading" class="loading" hidden>
            <div class="spinner"></div>
            <p>Analyzing vehicle damage...</p>
        </div>

        <!-- Error message container -->
        <div id="error" class="error-message" hidden></div>

        <!-- Damage report results -->
        <div id="report" class="report" hidden>
            <h2>Damage Assessment Report</h2>
            <div id="reportSummary" class="report-summary"></div>
            <div id="reportDamages" class="report-damages"></div>
        </div>
    </div>

    <script>

What does this HTML do?

  • The dropZone div acts as the drag-and-drop target. The hidden attribute on the file input means users interact with the styled drop zone instead of the default browser file picker.
  • The preview div starts hidden and only appears once a file is selected. This gives users visual confirmation of their upload before sending it.
  • The report div has two children: reportSummary for the overall assessment and reportDamages for individual damage cards. JavaScript will populate these dynamically.

Do not save the file yet. You will add the JavaScript in the next two substeps before closing the <script> tag.

Write the file handling and API JavaScript

The JavaScript needs to handle three user interactions: dragging a file onto the drop zone, clicking to browse for a file, and clicking the analyze button to send the image to your backend.

  • Directly below the <script> tag you just wrote, paste the following JavaScript:
        // Cache references to all DOM elements we'll interact with
        const dropZone = document.getElementById("dropZone");
        const fileInput = document.getElementById("fileInput");
        const preview = document.getElementById("preview");
        const previewImage = document.getElementById("previewImage");
        const clearBtn = document.getElementById("clearBtn");
        const analyzeBtn = document.getElementById("analyzeBtn");
        const loading = document.getElementById("loading");
        const errorDiv = document.getElementById("error");
        const report = document.getElementById("report");
        const reportSummary = document.getElementById("reportSummary");
        const reportDamages = document.getElementById("reportDamages");

        let selectedFile = null;

        // Clicking the drop zone opens the hidden file picker
        dropZone.addEventListener("click", () => fileInput.click());

        // Highlight the zone when a file is dragged over it
        dropZone.addEventListener("dragover", (e) => {
            e.preventDefault();
            dropZone.classList.add("drag-over");
        });

        dropZone.addEventListener("dragleave", () => {
            dropZone.classList.remove("drag-over");
        });

        // Handle the file when dropped
        dropZone.addEventListener("drop", (e) => {
            e.preventDefault();
            dropZone.classList.remove("drag-over");
            const files = e.dataTransfer.files;
            if (files.length > 0) handleFile(files[0]);
        });

        // Also handle file selection via the browse dialog
        fileInput.addEventListener("change", (e) => {
            if (e.target.files.length > 0) handleFile(e.target.files[0]);
        });

        // Reset everything when the user clicks Clear
        clearBtn.addEventListener("click", () => {
            selectedFile = null;
            fileInput.value = "";
            preview.hidden = true;
            analyzeBtn.hidden = true;
            dropZone.style.display = "";
            report.hidden = true;
            errorDiv.hidden = true;
        });

        // Validate the file type and show a preview
        function handleFile(file) {
            if (!file.type.match("image/(png|jpeg|webp)")) {
                showError("Please upload a PNG, JPG, or WEBP image.");
                return;
            }
            selectedFile = file;
            const reader = new FileReader();
            reader.onload = (e) => {
                previewImage.src = e.target.result;
                preview.hidden = false;
                analyzeBtn.hidden = false;
                dropZone.style.display = "none";
                report.hidden = true;
                errorDiv.hidden = true;
            };
            reader.readAsDataURL(file);
        }

        // Send the image to the backend and handle the response
        analyzeBtn.addEventListener("click", async () => {
            if (!selectedFile) return;

            loading.hidden = false;
            analyzeBtn.hidden = true;
            report.hidden = true;
            errorDiv.hidden = true;

            const formData = new FormData();
            formData.append("image", selectedFile);

            try {
                const response = await fetch("/analyze", {
                    method: "POST",
                    body: formData,
                });
                const data = await response.json();

                if (!response.ok) {
                    showError(data.error || "Analysis failed.");
                    analyzeBtn.hidden = false;
                    return;
                }

                displayReport(data);
            } catch (err) {
                showError("Failed to connect to the server. Make sure it is running.");
                analyzeBtn.hidden = false;
            } finally {
                loading.hidden = true;
            }
        });

        // Display an error message to the user
        function showError(message) {
            errorDiv.textContent = message;
            errorDiv.hidden = false;
        }

What does this JavaScript do?

  • The event listeners on dropZone handle three drag events: dragover adds a highlight class, dragleave removes it, and drop grabs the file. The e.preventDefault() calls stop the browser from opening the file directly.
  • handleFile validates the MIME type, then uses a FileReader to generate a data URL for the image preview. This lets you show the photo without uploading it yet.
  • The analyze button builds a FormData object and POSTs it to /analyze. The key "image" matches what your Flask route expects from request.files["image"].

Do not save yet. One more section of JavaScript to add.

Build the report display logic

The display functions take the JSON response from your API and render it as readable cards. Each damage item gets a color-coded severity badge so the user can scan results at a glance.

  • Directly below the showError function you just added, paste the display logic and the closing tags:
        // Map severity levels to CSS class names for color coding
        function getSeverityClass(severity) {
            if (severity === "minor") return "severity-minor";
            if (severity === "moderate") return "severity-moderate";
            if (severity === "severe") return "severity-severe";
            return "";
        }

        // Render the full damage report into the page
        function displayReport(data) {
            const detected = data.vehicle_detected;
            const overallClass = data.overall_severity ? getSeverityClass(data.overall_severity) : "";

            let summaryHTML = "";
            if (!detected) {
                // No vehicle found in the image
                summaryHTML = `<div class="summary-card no-vehicle">
                    <h3>No Vehicle Detected</h3>
                    <p>${data.summary}</p>
                </div>`;
            } else if (data.damages.length === 0) {
                // Vehicle found but no damage visible
                summaryHTML = `<div class="summary-card no-damage">
                    <h3>No Damage Detected</h3>
                    <p>${data.summary}</p>
                </div>`;
            } else {
                // Normal case: vehicle with damage
                summaryHTML = `<div class="summary-card">
                    <div class="summary-header">
                        <h3>Overall Assessment</h3>
                        <span class="severity-badge ${overallClass}">${data.overall_severity || "N/A"}</span>
                    </div>
                    <p>${data.summary}</p>
                    <p class="damage-count">${data.damages.length} damage area(s) identified</p>
                </div>`;
            }
            reportSummary.innerHTML = summaryHTML;

            // Build a card for each individual damage item
            let damagesHTML = "";
            data.damages.forEach((item) => {
                const sevClass = getSeverityClass(item.severity);
                const confidence = Math.round(item.confidence * 100);
                damagesHTML += `<div class="damage-card">
                    <div class="damage-header">
                        <h4>${item.part}</h4>
                        <span class="severity-badge ${sevClass}">${item.severity}</span>
                    </div>
                    <div class="damage-details">
                        <div class="detail"><span class="label">Type</span><span class="value">${item.damage_type}</span></div>
                        <div class="detail"><span class="label">Action</span><span class="value">${item.repair_action}</span></div>
                        <div class="detail"><span class="label">Confidence</span><span class="value">${confidence}%</span></div>
                    </div>
                </div>`;
            });
            reportDamages.innerHTML = damagesHTML;

            report.hidden = false;
            analyzeBtn.hidden = false;
        }
    </script>
</body>
</html>

What does this display code do?

  • getSeverityClass maps each severity string to a CSS class. These classes will apply green (minor), orange (moderate), or red (severe) badge colors once you add styling in the next step.
  • displayReport handles three scenarios: no vehicle detected, vehicle with no damage, and vehicle with damage. It builds HTML strings using template literals and injects them with innerHTML.
  • Each damage card shows the part name, a severity badge, the damage type, the recommended repair action, and the confidence score converted to a percentage.
  • Save templates/index.html.
  • Make sure your Flask server is still running. If you stopped it, start it again by running:
python app.py
  • Open your browser and go to http://127.0.0.1:5000.
  • You should see the text "Vehicle Damage Assessor" and a drop zone that says "Drag and drop a vehicle photo here". The page will look unstyled because you have not added CSS yet.
  • Drag a car damage photo onto the drop zone (or click to browse). You should see a preview of the image and an "Analyze Damage" button appear.
  • Click Analyze Damage. After a few seconds, you should see a damage report with severity badges and individual damage cards.

Page is blank or shows an error?

  • If you see a TemplateNotFound error in your terminal, make sure your file is saved inside the templates folder (not the project root).
  • If clicking Analyze Damage shows "Failed to connect to the server", confirm your Flask server is running in the terminal on port 5000.
  • If you see "Analysis failed" in the error message, check that your GEMINI_API_KEY in .env is still valid.
  • Help me debug my frontend.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete templates/index.html file. Make sure your version matches this exactly.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vehicle Damage Assessor</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    <div class="container">
        <header>
            <h1>Vehicle Damage Assessor</h1>
            <p>Upload a photo of vehicle damage for AI-powered analysis</p>
        </header>

        <div class="upload-section">
            <div id="dropZone" class="drop-zone">
                <div class="drop-zone-content">
                    <span class="drop-zone-icon">📸</span>
                    <p>Drag and drop a vehicle photo here</p>
                    <p class="drop-zone-hint">or click to browse</p>
                </div>
                <input type="file" id="fileInput" accept="image/png,image/jpeg,image/webp" hidden>
            </div>
            <div id="preview" class="preview" hidden>
                <img id="previewImage" alt="Uploaded vehicle photo">
                <button id="clearBtn" class="btn-clear">Clear</button>
            </div>
            <button id="analyzeBtn" class="btn-analyze" hidden>Analyze Damage</button>
        </div>

        <div id="loading" class="loading" hidden>
            <div class="spinner"></div>
            <p>Analyzing vehicle damage...</p>
        </div>

        <div id="error" class="error-message" hidden></div>

        <div id="report" class="report" hidden>
            <h2>Damage Assessment Report</h2>
            <div id="reportSummary" class="report-summary"></div>
            <div id="reportDamages" class="report-damages"></div>
        </div>
    </div>

    <script>
        const dropZone = document.getElementById("dropZone");
        const fileInput = document.getElementById("fileInput");
        const preview = document.getElementById("preview");
        const previewImage = document.getElementById("previewImage");
        const clearBtn = document.getElementById("clearBtn");
        const analyzeBtn = document.getElementById("analyzeBtn");
        const loading = document.getElementById("loading");
        const errorDiv = document.getElementById("error");
        const report = document.getElementById("report");
        const reportSummary = document.getElementById("reportSummary");
        const reportDamages = document.getElementById("reportDamages");

        let selectedFile = null;

        dropZone.addEventListener("click", () => fileInput.click());

        dropZone.addEventListener("dragover", (e) => {
            e.preventDefault();
            dropZone.classList.add("drag-over");
        });

        dropZone.addEventListener("dragleave", () => {
            dropZone.classList.remove("drag-over");
        });

        dropZone.addEventListener("drop", (e) => {
            e.preventDefault();
            dropZone.classList.remove("drag-over");
            const files = e.dataTransfer.files;
            if (files.length > 0) handleFile(files[0]);
        });

        fileInput.addEventListener("change", (e) => {
            if (e.target.files.length > 0) handleFile(e.target.files[0]);
        });

        clearBtn.addEventListener("click", () => {
            selectedFile = null;
            fileInput.value = "";
            preview.hidden = true;
            analyzeBtn.hidden = true;
            dropZone.style.display = "";
            report.hidden = true;
            errorDiv.hidden = true;
        });

        function handleFile(file) {
            if (!file.type.match("image/(png|jpeg|webp)")) {
                showError("Please upload a PNG, JPG, or WEBP image.");
                return;
            }
            selectedFile = file;
            const reader = new FileReader();
            reader.onload = (e) => {
                previewImage.src = e.target.result;
                preview.hidden = false;
                analyzeBtn.hidden = false;
                dropZone.style.display = "none";
                report.hidden = true;
                errorDiv.hidden = true;
            };
            reader.readAsDataURL(file);
        }

        analyzeBtn.addEventListener("click", async () => {
            if (!selectedFile) return;

            loading.hidden = false;
            analyzeBtn.hidden = true;
            report.hidden = true;
            errorDiv.hidden = true;

            const formData = new FormData();
            formData.append("image", selectedFile);

            try {
                const response = await fetch("/analyze", {
                    method: "POST",
                    body: formData,
                });
                const data = await response.json();

                if (!response.ok) {
                    showError(data.error || "Analysis failed.");
                    analyzeBtn.hidden = false;
                    return;
                }

                displayReport(data);
            } catch (err) {
                showError("Failed to connect to the server. Make sure it is running.");
                analyzeBtn.hidden = false;
            } finally {
                loading.hidden = true;
            }
        });

        function showError(message) {
            errorDiv.textContent = message;
            errorDiv.hidden = false;
        }

        function getSeverityClass(severity) {
            if (severity === "minor") return "severity-minor";
            if (severity === "moderate") return "severity-moderate";
            if (severity === "severe") return "severity-severe";
            return "";
        }

        function displayReport(data) {
            const detected = data.vehicle_detected;
            const overallClass = data.overall_severity ? getSeverityClass(data.overall_severity) : "";

            let summaryHTML = "";
            if (!detected) {
                summaryHTML = `<div class="summary-card no-vehicle">
                    <h3>No Vehicle Detected</h3>
                    <p>${data.summary}</p>
                </div>`;
            } else if (data.damages.length === 0) {
                summaryHTML = `<div class="summary-card no-damage">
                    <h3>No Damage Detected</h3>
                    <p>${data.summary}</p>
                </div>`;
            } else {
                summaryHTML = `<div class="summary-card">
                    <div class="summary-header">
                        <h3>Overall Assessment</h3>
                        <span class="severity-badge ${overallClass}">${data.overall_severity || "N/A"}</span>
                    </div>
                    <p>${data.summary}</p>
                    <p class="damage-count">${data.damages.length} damage area(s) identified</p>
                </div>`;
            }
            reportSummary.innerHTML = summaryHTML;

            let damagesHTML = "";
            data.damages.forEach((item) => {
                const sevClass = getSeverityClass(item.severity);
                const confidence = Math.round(item.confidence * 100);
                damagesHTML += `<div class="damage-card">
                    <div class="damage-header">
                        <h4>${item.part}</h4>
                        <span class="severity-badge ${sevClass}">${item.severity}</span>
                    </div>
                    <div class="damage-details">
                        <div class="detail"><span class="label">Type</span><span class="value">${item.damage_type}</span></div>
                        <div class="detail"><span class="label">Action</span><span class="value">${item.repair_action}</span></div>
                        <div class="detail"><span class="label">Confidence</span><span class="value">${confidence}%</span></div>
                    </div>
                </div>`;
            });
            reportDamages.innerHTML = damagesHTML;

            report.hidden = false;
            analyzeBtn.hidden = false;
        }
    </script>
</body>
</html>

Your app is fully functional now. You can upload damage photos and get AI-generated reports. Next up, you will add CSS styling to give the interface a polished, professional look with proper color coding for severity levels.

Style the App and Add Polish

Your app works, but right now it looks like raw, unstyled HTML. A professional-looking interface builds trust with users and makes the damage report easier to read at a glance.

In this step, you will add a dark-themed automotive design to the app. You will also style the loading spinner, error messages, and color-coded severity badges that make each damage report scannable.

In this step, get ready to:
  • Add CSS for the base layout, upload zone, and dark color scheme.
  • Style the loading spinner, error messages, and damage report cards.
  • Run the full app end-to-end and test edge cases.
Style the base layout and upload zone

The static/style.css file is currently empty. You will build up the stylesheet in sections, starting with the page foundation and upload interaction area.

  • Open static/style.css in Cursor and add the base reset and layout styles:
/* Reset default browser spacing */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

/* Dark theme body */
body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background-color: #0f1117;
    color: #e1e4e8;
    min-height: 100vh;
}

/* Centered content column */
.container {
    max-width: 720px;
    margin: 0 auto;
    padding: 40px 20px;
}

/* Page header */
header {
    text-align: center;
    margin-bottom: 32px;
}

header h1 {
    font-size: 28px;
    color: #ffffff;
    margin-bottom: 8px;
}

header p {
    color: #8b949e;
    font-size: 16px;
}

What does this code do?

The * reset removes default margins and padding so every element starts from a clean slate.

The body uses a very dark background (#0f1117) with light text (#e1e4e8) for a professional automotive feel. The container keeps content centered at a readable 720px width.

  • Save static/style.css.
  • Refresh your browser at http://localhost:5000. You should see the page switch from a white background to a dark theme with centered content.

Still seeing a white background?

Make sure your Flask app is still running in the terminal. If you stopped it, restart it by running python app.py from your project folder.

Check that the file is saved at static/style.css (inside the static folder, not the project root). Help me debug why my CSS is not loading.

  • Below the header styles, add the upload zone and button styles:
/* Dashed upload drop zone */
.drop-zone {
    border: 2px dashed #30363d;
    border-radius: 12px;
    padding: 48px 24px;
    text-align: center;
    cursor: pointer;
    transition: border-color 0.2s, background-color 0.2s;
    background-color: #161b22;
}

/* Highlight when hovering or dragging over */
.drop-zone:hover,
.drop-zone.drag-over {
    border-color: #58a6ff;
    background-color: #1c2333;
}

.drop-zone-icon {
    font-size: 48px;
    display: block;
    margin-bottom: 12px;
}

.drop-zone p {
    color: #c9d1d9;
    margin-bottom: 4px;
}

.drop-zone-hint {
    color: #58a6ff;
    font-size: 14px;
}

/* Image preview area */
.preview {
    text-align: center;
    margin-top: 16px;
}

.preview img {
    max-width: 100%;
    max-height: 400px;
    border-radius: 8px;
    border: 1px solid #30363d;
}

/* Clear button */
.btn-clear {
    margin-top: 12px;
    padding: 8px 20px;
    background: transparent;
    border: 1px solid #30363d;
    color: #c9d1d9;
    border-radius: 6px;
    cursor: pointer;
    font-size: 14px;
}

.btn-clear:hover {
    border-color: #8b949e;
}

/* Green analyze button */
.btn-analyze {
    display: block;
    width: 100%;
    margin-top: 16px;
    padding: 14px;
    background-color: #238636;
    color: #ffffff;
    border: none;
    border-radius: 8px;
    font-size: 16px;
    font-weight: 600;
    cursor: pointer;
    transition: background-color 0.2s;
}

.btn-analyze:hover {
    background-color: #2ea043;
}

What does this code do?

The .drop-zone creates a dashed border area that lights up blue when the user hovers or drags a file over it. The transition property makes the color change smooth.

The .btn-analyze uses a green background (#238636) to draw attention as the primary action. The preview image is constrained to 400px height so it never takes over the screen.

  • Save static/style.css and refresh your browser.
  • You should see the upload zone appear as a rounded rectangle with a dashed border. Hover over it to confirm the blue highlight effect works.

Drop zone not turning blue on hover?

Check that the class name .drop-zone in your CSS matches class="drop-zone" in your HTML. Class names are case-sensitive. Help me debug my drop zone hover style.

Add loading, error, and report display styles

The HTML already has a spinner div and error message div from the previous step. Now you need the CSS that makes the spinner animate and the error message stand out visually.

  • Below the button styles in static/style.css, add the loading spinner and error styles:
/* Loading state */
.loading {
    text-align: center;
    padding: 32px;
}

/* Spinning circle animation */
.spinner {
    width: 40px;
    height: 40px;
    border: 3px solid #30363d;
    border-top-color: #58a6ff;
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
    margin: 0 auto 16px;
}

@keyframes spin {
    to { transform: rotate(360deg); }
}

/* Red error banner */
.error-message {
    background-color: #3d1f28;
    border: 1px solid #f85149;
    color: #f85149;
    padding: 12px 16px;
    border-radius: 8px;
    margin-top: 16px;
    font-size: 14px;
}

What does this code do?

The .spinner creates a circle with one blue edge. The @keyframes spin rule rotates it 360 degrees continuously, creating the loading animation the user sees while Gemini processes the image.

The .error-message uses a dark red background with bright red text to clearly signal problems like invalid file types or API failures.

  • Save static/style.css.
  • You won't see the spinner or error yet. They only appear when you upload an image or trigger an error. The next chunk adds the report styles, and then you will test everything together.
  • Below the error styles, add the damage report card and severity badge styles:
/* Report section */
.report {
    margin-top: 32px;
}

.report h2 {
    font-size: 22px;
    margin-bottom: 16px;
    color: #ffffff;
}

/* Summary card */
.summary-card {
    background-color: #161b22;
    border: 1px solid #30363d;
    border-radius: 10px;
    padding: 20px;
    margin-bottom: 20px;
}

.summary-card.no-vehicle,
.summary-card.no-damage {
    border-color: #30363d;
}

.summary-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 12px;
}

.summary-card h3 {
    font-size: 18px;
    color: #ffffff;
}

.summary-card p {
    color: #c9d1d9;
    line-height: 1.5;
    margin-bottom: 8px;
}

.damage-count {
    color: #8b949e;
    font-size: 14px;
}

/* Color-coded severity badges */
.severity-badge {
    display: inline-block;
    padding: 4px 12px;
    border-radius: 20px;
    font-size: 12px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

.severity-minor {
    background-color: #1b3a2d;
    color: #3fb950;
}

.severity-moderate {
    background-color: #3d2e00;
    color: #d29922;
}

.severity-severe {
    background-color: #3d1f28;
    color: #f85149;
}

/* Individual damage cards */
.damage-card {
    background-color: #161b22;
    border: 1px solid #30363d;
    border-radius: 10px;
    padding: 16px;
    margin-bottom: 12px;
}

.damage-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 12px;
}

.damage-header h4 {
    font-size: 16px;
    color: #ffffff;
    text-transform: capitalize;
}

/* Three-column detail grid */
.damage-details {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 12px;
}

.detail {
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.detail .label {
    font-size: 12px;
    color: #8b949e;
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

.detail .value {
    font-size: 14px;
    color: #c9d1d9;
    text-transform: capitalize;
}

What does this code do?

The severity badges use color coding so users can scan damage at a glance. Green (.severity-minor) means low concern. Orange (.severity-moderate) means moderate. Red (.severity-severe) means urgent.

Each damage card uses a three-column grid (.damage-details) to display the damage type, repair action, and confidence side by side. This keeps the report compact and professional.

  • Save static/style.css and refresh your browser.
  • The upload zone should look polished with its dark background and dashed border. The report styles will be visible once you upload and analyze an image in the next substep.

Seeing broken layout or overlapping elements?

Make sure the CSS reset (* { margin: 0; padding: 0; }) is at the very top of your file. If it comes after other styles, it will override them.

Check for missing closing braces. A single unclosed } will break everything after it. Help me find the CSS syntax error in my style.css.

✔️ Awesome, I've got everything!

Great. Double check you have saved static/style.css before moving on.

ⓧ I'd like to double check the full code

Here is the complete static/style.css file. Make sure yours matches exactly.

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background-color: #0f1117;
    color: #e1e4e8;
    min-height: 100vh;
}

.container {
    max-width: 720px;
    margin: 0 auto;
    padding: 40px 20px;
}

header {
    text-align: center;
    margin-bottom: 32px;
}

header h1 {
    font-size: 28px;
    color: #ffffff;
    margin-bottom: 8px;
}

header p {
    color: #8b949e;
    font-size: 16px;
}

.drop-zone {
    border: 2px dashed #30363d;
    border-radius: 12px;
    padding: 48px 24px;
    text-align: center;
    cursor: pointer;
    transition: border-color 0.2s, background-color 0.2s;
    background-color: #161b22;
}

.drop-zone:hover,
.drop-zone.drag-over {
    border-color: #58a6ff;
    background-color: #1c2333;
}

.drop-zone-icon {
    font-size: 48px;
    display: block;
    margin-bottom: 12px;
}

.drop-zone p {
    color: #c9d1d9;
    margin-bottom: 4px;
}

.drop-zone-hint {
    color: #58a6ff;
    font-size: 14px;
}

.preview {
    text-align: center;
    margin-top: 16px;
}

.preview img {
    max-width: 100%;
    max-height: 400px;
    border-radius: 8px;
    border: 1px solid #30363d;
}

.btn-clear {
    margin-top: 12px;
    padding: 8px 20px;
    background: transparent;
    border: 1px solid #30363d;
    color: #c9d1d9;
    border-radius: 6px;
    cursor: pointer;
    font-size: 14px;
}

.btn-clear:hover {
    border-color: #8b949e;
}

.btn-analyze {
    display: block;
    width: 100%;
    margin-top: 16px;
    padding: 14px;
    background-color: #238636;
    color: #ffffff;
    border: none;
    border-radius: 8px;
    font-size: 16px;
    font-weight: 600;
    cursor: pointer;
    transition: background-color 0.2s;
}

.btn-analyze:hover {
    background-color: #2ea043;
}

.loading {
    text-align: center;
    padding: 32px;
}

.spinner {
    width: 40px;
    height: 40px;
    border: 3px solid #30363d;
    border-top-color: #58a6ff;
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
    margin: 0 auto 16px;
}

@keyframes spin {
    to { transform: rotate(360deg); }
}

.error-message {
    background-color: #3d1f28;
    border: 1px solid #f85149;
    color: #f85149;
    padding: 12px 16px;
    border-radius: 8px;
    margin-top: 16px;
    font-size: 14px;
}

.report {
    margin-top: 32px;
}

.report h2 {
    font-size: 22px;
    margin-bottom: 16px;
    color: #ffffff;
}

.summary-card {
    background-color: #161b22;
    border: 1px solid #30363d;
    border-radius: 10px;
    padding: 20px;
    margin-bottom: 20px;
}

.summary-card.no-vehicle,
.summary-card.no-damage {
    border-color: #30363d;
}

.summary-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 12px;
}

.summary-card h3 {
    font-size: 18px;
    color: #ffffff;
}

.summary-card p {
    color: #c9d1d9;
    line-height: 1.5;
    margin-bottom: 8px;
}

.damage-count {
    color: #8b949e;
    font-size: 14px;
}

.severity-badge {
    display: inline-block;
    padding: 4px 12px;
    border-radius: 20px;
    font-size: 12px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

.severity-minor {
    background-color: #1b3a2d;
    color: #3fb950;
}

.severity-moderate {
    background-color: #3d2e00;
    color: #d29922;
}

.severity-severe {
    background-color: #3d1f28;
    color: #f85149;
}

.damage-card {
    background-color: #161b22;
    border: 1px solid #30363d;
    border-radius: 10px;
    padding: 16px;
    margin-bottom: 12px;
}

.damage-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 12px;
}

.damage-header h4 {
    font-size: 16px;
    color: #ffffff;
    text-transform: capitalize;
}

.damage-details {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 12px;
}

.detail {
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.detail .label {
    font-size: 12px;
    color: #8b949e;
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

.detail .value {
    font-size: 14px;
    color: #c9d1d9;
    text-transform: capitalize;
}
Test the complete app end-to-end

With all the styling in place, it is time to put the app through its paces. You will test normal damage photos, edge cases, and error handling to make sure everything works together.

  • Make sure your Flask app is still running. If not, start it by running:
python app.py
  • Open http://localhost:5000 in your browser.
  • Search Google Images for "car damage dent" or "car scratch" and save a photo to your computer.
  • Drag the photo onto the upload zone (or click to browse and select it).
  • Confirm the image preview appears and the green Analyze Damage button shows up.
  • Click Analyze Damage.

You should see the blue spinner animate while Gemini processes the image. After a few seconds, the damage report appears with color-coded severity badges (green, orange, or red) and individual damage cards.

What should I see?

For a typical car damage photo, the report should show one or more damage cards listing the affected part (e.g., "front bumper"), damage type (e.g., "dent"), severity badge, and a recommended repair action. The summary at the top describes the overall damage in plain language.

  • Now test the edge cases. Save a photo that is clearly not a car (a landscape, a pet, food) and upload it.
  • Click Analyze Damage. The report should show "No Vehicle Detected" with a summary explaining that no vehicle was found.
  • Test the error handling by trying to upload a non-image file (like a .pdf or .txt). You should see a red error message saying "Please upload a PNG, JPG, or WEBP image."

Getting "Analysis failed" errors?

Check your terminal for the full error message. If it mentions an API key issue, make sure your .env file has a valid GEMINI_API_KEY and that you restarted the Flask app after adding it.

If you see a rate limit error, wait 60 seconds and try again. The free tier has per-minute limits. Help me debug my Gemini API error.

Your vehicle damage assessment app is fully functional and polished. You have a dark-themed interface with drag-and-drop uploads, animated loading feedback, and a professional damage report dashboard. Next, tackle the Secret Mission to add multi-image support for even more comprehensive assessments.

Secret mission

Multi-Image Assessment

A single photo can miss damage hidden behind a bumper or along the side of a vehicle. Real insurance assessors walk around the entire car, capturing damage from multiple angles. Can you upgrade your app to accept multiple photos and produce a more comprehensive assessment?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a free-tier API key, so there are no ongoing costs.

Resources you used:

  • Project folder with app.py, templates/index.html, static/style.css, and .env.
  • Python virtual environment (venv folder).
  • Installed Python packages (flask, google-genai, python-dotenv).
  • Google AI Studio API key.

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment with different damage photos.

  • Your project folder stays on your Desktop.
  • Your Gemini API key remains active in Google AI Studio.
  • You can restart the app anytime by activating the virtual environment and running python app.py.

Pause - I'll come back to this later

Shut down the Flask development server to free up your terminal. Your files and API key stay intact for next time.

  • Press Ctrl+C in the terminal where the Flask server is running to stop it.
  • Run deactivate to exit the Python virtual environment.
  • Your project folder and .env file remain on your Desktop for next time.

Delete - I don't want to use this again

Remove all project files from your machine and revoke your API key.

  • Press Ctrl+C in the terminal to stop the Flask server if it is still running.
  • Run deactivate to exit the virtual environment.
  • Delete the entire project folder from your Desktop.
  • Go to aistudio.google.com and delete your API key from the API keys page if you no longer need it.

Nice Work!

Nice Work!

You just built an AI-powered vehicle damage assessment tool. That is the same category of application that enterprise companies charge thousands of dollars for.

Here's what you learned:

  • Integrated Google Gemini's vision API with a Python Flask backend to analyze vehicle damage photos using the google-genai SDK.
  • Designed a Pydantic structured output schema that enforces consistent, machine-readable JSON damage reports from the AI model.
  • Crafted a specialized damage assessment prompt that identifies damaged parts, classifies damage types, rates severity levels, and recommends repair actions.
  • Added multi-image support for comprehensive multi-angle vehicle assessments.

Ready to quiz yourself?