Documentation

Floor Plan API takes a floor plan image and returns a binary wall-segmentation PNG mask of the same dimensions. Every capability is available through the official Python library and the raw REST API — pick whichever fits your stack.

Introduction

Floor Plan API runs a U-Net++ wall-segmentation model on the image you upload and returns the binary mask as a PNG. Foreground pixels (255) mark walls; background pixels (0) mark everything else. The mask matches the input's resolution exactly, so it lines up pixel-for-pixel with the image you sent.

Two ways to call the API, both fully supported and documented end-to-end:

  • Python library pip install floorplan-api. Typed client, automatic retries, sync + async helpers. See Python library.
  • REST API — plain HTTP from any language or HTTP client (curl, Node fetch, Go, Ruby, …). See REST API.

Quickstart

1. Get an API key

Create an account, then generate a key from the dashboard. Test keys (fp_test_…) use the same model but aren't billed.

Create account

2. Make your first request

pip install floorplan-api

# in Python:
from floorplan_api import Client

client = Client(api_key="fp_test_...")
mask_png = client.extract("plan.png")

with open("walls.png", "wb") as fh:
    fh.write(mask_png)

print(f"Saved {len(mask_png)} bytes of wall mask")

3. Inspect the response

On success the response body is a single-channel PNG withContent-Type: image/png. Useful headers:

HTTP/1.1 200 OK
Content-Type: image/png
Content-Length: 18432
X-Mask-Width: 1024
X-Mask-Height: 768
X-Job-Id: job_abc123
X-Request-Id: req_def456

Mask output

The output of every successful inference call is a single PNG. Knowing exactly what's in it makes the rest of the docs simpler.

PropertyValue
formatPNG
channels1 (single-channel grayscale)
bit depth8
pixel values0 = background; 255 = wall. No greys — the mask is binarised at the API boundary.
resolutionIdentical to the input image. The mask aligns pixel-for-pixel with the image you uploaded.
colour spaceNone — single-channel grayscale, no embedded ICC profile.

See Working with the mask for Python recipes that overlay the mask on the original image, count wall pixels, or convert it to other formats.

Authentication

Every request must carry an API key in the Authorization header:

Authorization: Bearer fp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Live keys — prefix fp_live_. Charged against your account, write usage records.
Test keys — prefix fp_test_. Run the same model, never billed, never logged as usage.
Storage — keys are shown onceafter creation, then stored hashed. You can revoke any key from the dashboard at any time.
Rotation — create a new key, deploy it, revoke the old one. Up to 10 active keys per account.

Python library

The official client — sync, typed, with retries built in. Works on Python 3.8+. Source is in web/python_lib/.

Install

pip install floorplan-api

Only runtime dependency is requests. PIL / OpenCV / NumPy are optional and only needed if you want to process the returned mask in Python.

Initialise the client

from floorplan_api import Client

client = Client(
    api_key="fp_live_...",
    timeout=60.0,
    max_retries=3,
    retry_backoff=0.5,
)

extract()

Synchronous wall segmentation. Accepts a path, raw bytes, or a binary file-like object. Returns the raw PNG mask bytes.

from pathlib import Path

# any of these works:
mask = client.extract("plans/floor1.png")            # path
mask = client.extract(Path("plans/floor1.png"))      # pathlib.Path
mask = client.extract(image_bytes)                   # raw bytes
with open("plan.jpg", "rb") as fp:
    mask = client.extract(fp)                        # file-like

# Per-call timeout override:
mask = client.extract("plan.png", timeout=120.0)

Supported MIME types: PNG, JPEG, WEBP. Max upload size 10 MB. The returned bytes are a single-channel PNG (see Mask output).

analyze()

Currently produces the same wall mask as extract(). Costs 2 credits per call. Kept distinct so future capabilities (per-room classification, symbol detection) can attach to analyze without changing extract's contract.

mask_png = client.analyze("plan.png")

Large files (presigned uploads)

The inline extract() / analyze() path streams the bytes through the API server, which caps the request at 10 MB. For larger images, the client uploads directly to object storage via a presigned URL the API hands back, then submits the job by storage key — the API server never holds the bytes.

upload_then_extract() is the one-shot helper. Files smaller than threshold_bytes (default 10 MiB) go through the inline path; larger files switch to the presigned flow automatically.

# Same return value as extract() — bytes (the PNG mask).
mask_png = client.upload_then_extract("big_floor_plan.png")

# Force the presigned path even for small files (e.g. CI smoke tests):
mask_png = client.upload_then_extract("plan.png", threshold_bytes=0)

If you need finer control — say, you already have the bytes in S3 and just want to extract — use the two-step API:

# 1. Reserve a slot. Returns {upload_url, key, expires_at, content_type}.
slot = client.create_upload(content_type="image/png")

# 2. PUT the bytes (any HTTP client; we use the built-in session here).
import requests
with open("plan.png", "rb") as fp:
    res = requests.put(
        slot["upload_url"],
        data=fp,
        headers={"Content-Type": slot["content_type"]},
    )
    res.raise_for_status()

# 3. Or, with the helper that wraps create_upload + PUT:
upload_key = client.upload("plan.png")

# 4. Run extract by storage key (no bytes flow through the API server).
import requests
mask = requests.post(
    f"{client.base_url}/v1/extract",
    headers={"Authorization": f"Bearer {client.api_key}"},
    files={"upload_key": (None, upload_key)},
).content

Async jobs

For batches or longer-running analyses, submit a job and poll for completion, then download the mask. Three calls cover the full lifecycle:

StepMethodReturns
1. Submitanalyze_async(image)Job (status="queued")
2. Wait or pollwait_for_job(job.id) / get_job(id)Job (terminal)
3. Downloaddownload_mask(job.id)bytes (PNG)

Complete walkthrough:

from floorplan_api import Client, FloorPlanError

with Client() as client:
    job = client.analyze_async("plan.png")
    print(f"Submitted {job.id} (status={job.status})")

    final = client.wait_for_job(
        job.id,
        poll_interval=2.0,    # seconds between polls
        timeout=300.0,        # raise TimeoutError after 5 min
    )
    print(f"Final status: {final.status}")

    if final.status != "completed" or final.result is None:
        raise FloorPlanError(final.error["message"] if final.error else "no result")

    print(f"Mask is {final.result.width}x{final.result.height}px")
    mask_png = client.download_mask(final.id)

    with open("walls.png", "wb") as fh:
        fh.write(mask_png)

Or poll yourself with custom logic:

import time

job = client.analyze_async("plan.png")
while not job.is_terminal:
    time.sleep(1.0)
    job = client.get_job(job.id)

Working with the mask

The bytes you get back are a standard PNG, so any image library works. A few common patterns:

from io import BytesIO
from PIL import Image

mask_png = client.extract("plan.png")

# Decode the PNG and inspect it.
mask_img = Image.open(BytesIO(mask_png))
print(mask_img.size)        # (width, height) — same as the input image
print(mask_img.mode)        # "L" — single-channel 8-bit grayscale

# Save somewhere else, convert format, etc.
mask_img.save("walls.tiff")
mask_img.convert("1").save("walls_bw.png")     # 1-bit bitmap

Error handling

import time
from floorplan_api import (
    Client, FloorPlanError,
    AuthenticationError, RateLimitError, InvalidRequestError,
    NotFoundError, ServerError, TimeoutError, ConnectionError,
)

client = Client()

try:
    mask = client.extract("plan.png")
except RateLimitError as exc:
    time.sleep(exc.retry_after or 5.0)
    mask = client.extract("plan.png")
except AuthenticationError:
    print("Invalid or revoked API key")
    raise
except InvalidRequestError as exc:
    # 400 / 413 / 415 — fix the input and retry
    print(f"bad request: {exc.message}")
    raise
except FloorPlanError as exc:
    # Anything else — keep the request_id for support tickets.
    print(f"{exc.type}: {exc.message} (request_id={exc.request_id})")
    raise

Server errors (5xx) and rate-limit responses (429) with Retry-After are retried automatically with exponential backoff up to max_retries times. Connection-level failures (DNS, TLS, timeout) are retried too.

Configuration

ArgumentDefaultDescription
api_keyenv FLOORPLAN_API_KEYYour API key.
base_urlhttps://api.floorplanapi.comOverride for self-hosted deployments. Also reads FLOORPLAN_BASE_URL.
timeout60.0Per-request timeout (seconds). Override per call with the keyword arg.
max_retries3Retries for connection errors, 5xx, and 429.
retry_backoff0.5Base delay (s) for exponential backoff with jitter.
sessionnewReuse a custom requests.Session.

Method reference

MethodReturnsREST equivalent
extract(image, *, timeout=None)bytesPOST /v1/extract
analyze(image, *, timeout=None)bytesPOST /v1/analyze
upload_then_extract(image, *, threshold_bytes=10_485_760, timeout=None)bytesPOST /v1/uploads + PUT + POST /v1/extract
create_upload(*, content_type="image/png", timeout=None)dictPOST /v1/uploads
upload(image, *, content_type=None, timeout=None)str (storage key)POST /v1/uploads + PUT
analyze_async(image, *, timeout=None)JobPOST /v1/analyze?async=true
get_job(job_id, *, timeout=None)JobGET /v1/status/{id}
wait_for_job(job_id, *, poll_interval=1.0, timeout=300.0)JobGET /v1/status/{id} (loop)
download_mask(job_id, *, timeout=None)bytesGET /v1/jobs/{id}/mask
close()NoneCloses the underlying session.

image can be a path (str / pathlib.Path), raw bytes, or any binary file-like object.

Types reference

@dataclass(frozen=True)
class MaskResult:
    result_url: str    # path on the API host (combine with base_url to GET)
    width: int         # pixels
    height: int        # pixels

@dataclass(frozen=True)
class Job:
    id: str
    status: Literal["queued","processing","completed","failed"]
    created_at: datetime
    completed_at: datetime | None
    result: MaskResult | None
    error: dict | None

    @property
    def is_terminal(self) -> bool: ...

# All exceptions derive from FloorPlanError:
class FloorPlanError(Exception):
    type: str | None
    message: str
    status_code: int | None
    request_id: str | None
    response: dict | None

class AuthenticationError(FloorPlanError): ...   # 401 / 403
class InvalidRequestError(FloorPlanError): ...   # 400 / 409 / 413 / 415 / other 4xx
class NotFoundError(FloorPlanError): ...         # 404
class RateLimitError(FloorPlanError):            # 429
    retry_after: float | None
class ServerError(FloorPlanError): ...           # 5xx
class TimeoutError(FloorPlanError): ...
class ConnectionError(FloorPlanError): ...

REST API

All requests use HTTPS. The hosted base URL is https://api.floorplanapi.com. When developing locally, use http://localhost:3000. Each endpoint below shows the same call in cURL, raw Python (requests), and Node.js (fetch).

Base URL

Production:

https://api.floorplanapi.com

Local development:

http://localhost:3000

Authorization header

Authorization: Bearer fp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

POST /v1/uploads

POST/v1/uploadsfree

Reserve a presigned URL the client can PUT bytes to directly. Use this to upload images larger than the inline 10 MB cap, or whenever you want to keep image bytes off the API server. After the PUT succeeds, pass the returned key as upload_key on /v1/extract or /v1/analyze.

Request body

Content-Type: application/json

{
  "content_type": "image/png"   // optional; defaults to "image/png"
                                // — must match the Content-Type used on PUT
}

Sample call

# 1. Reserve a slot.
curl -X POST https://api.floorplanapi.com/v1/uploads \
  -H "Authorization: Bearer fp_test_..." \
  -H "Content-Type: application/json" \
  -d '{"content_type":"image/png"}'

# Response:
# {"upload_url":"https://...","key":"uploads/<rand>.png","expires_at":"...","content_type":"image/png"}

# 2. PUT the bytes to upload_url with the same content_type.
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/png" \
  --data-binary @big_plan.png

# 3. Submit the job by storage key.
curl -X POST https://api.floorplanapi.com/v1/extract \
  -H "Authorization: Bearer fp_test_..." \
  -F "upload_key=$KEY" \
  --output walls.png

Response

HTTP/1.1 200 OK
Content-Type: application/json

{
  "upload_url": "https://<bucket>.<account>.r2.cloudflarestorage.com/uploads/...?X-Amz-...",
  "key": "uploads/<48-hex>.png",
  "expires_at": "2026-04-28T13:15:00.000Z",
  "content_type": "image/png"
}

Notes

  • The presigned URL expires 15 minutes after issuance.
  • The PUT must use the same Content-Type declared in the request body, or it will be rejected.
  • The returned key is opaque — pass it back as-is. The API never reuses keys across calls.

POST /v1/extract

POST/v1/extract1 credit

Run wall segmentation on a floor plan image. Synchronous; the response body on success is the binary PNG mask.

Request

  • Content-Type: multipart/form-data
  • One of (mutually exclusive):
  • image — PNG / JPEG / WEBP, ≤ 10 MB inline.
  • upload_key — storage key from POST /v1/uploads (recommended for files larger than 10 MB).

Sample call

# Inline (small files, ≤ 10 MB):
curl -X POST https://api.floorplanapi.com/v1/extract \
  -H "Authorization: Bearer fp_test_..." \
  -F "image=@plan.png" \
  --output walls.png

# By storage key (after POST /v1/uploads + PUT):
curl -X POST https://api.floorplanapi.com/v1/extract \
  -H "Authorization: Bearer fp_test_..." \
  -F "upload_key=uploads/<key>.png" \
  --output walls.png

Sample response (success)

HTTP/1.1 200 OK
Content-Type: image/png
Content-Length: 18432
X-Mask-Width: 1024
X-Mask-Height: 768
X-Job-Id: job_abc123
X-Request-Id: req_def456

<binary PNG bytes>

Possible status codes

  • 200 — success, body is the PNG mask.
  • 400 — missing/invalid image field.
  • 401 — missing / invalid / revoked / expired key.
  • 413 — image larger than 10 MB on the inline path; use POST /v1/uploads + upload_key instead.
  • 415 — unsupported MIME type.
  • 429 — rate limit exceeded.
  • 503 — queue full; honour Retry-After.
  • 504 — timed out before the worker finished. Poll /v1/status/{X-Job-Id}.

POST /v1/analyze

POST/v1/analyze2 credits

Same wall-segmentation model as /v1/extract for now. Sync by default; pass async=true to submit a job and receive a job_id instead of the mask.

Request fields

  • Content-Type: multipart/form-data
  • One of image or upload_key (required) — same semantics as /v1/extract.
  • async (default false) — set to true to submit asynchronously.

Sync call

curl -X POST https://api.floorplanapi.com/v1/analyze \
  -H "Authorization: Bearer fp_test_..." \
  -F "image=@plan.png" \
  --output walls.png

Async submit

curl -X POST https://api.floorplanapi.com/v1/analyze \
  -H "Authorization: Bearer fp_test_..." \
  -F "image=@plan.png" \
  -F "async=true"

Async submission response (202)

{
  "success": true,
  "job_id": "job_abc123",
  "status": "queued",
  "created_at": "2026-04-28T13:00:00.000Z",
  "mode": "test"
}

GET /v1/status/{job_id}

GET/v1/status/{job_id}free

Poll the status of an async job. When the job completes, the response carries a result_url pointing at the mask download endpoint below.

Sample call

curl https://api.floorplanapi.com/v1/status/job_abc123 \
  -H "Authorization: Bearer fp_test_..."

Response shape

{
  "job_id": "job_abc123",
  "status": "completed",   // or "queued" | "processing" | "failed"
  "created_at": "2026-04-28T13:00:00.000Z",
  "completed_at": "2026-04-28T13:00:02.000Z",
  "result": {
    "result_url": "/v1/jobs/job_abc123/mask",
    "width": 1024,
    "height": 768
  },
  "error": null            // or { "type": "...", "message": "..." }
}

Polling pattern

Suggested cadence: poll every 1–2 s for the first 30 s, then every 5 s, with a hard ceiling of a few minutes (depending on your batch size). The Python client's wait_for_job() does this for you.

import time, requests

def wait(job_id, *, key, base="https://api.floorplanapi.com", timeout=300):
    deadline = time.monotonic() + timeout
    while True:
        res = requests.get(f"{base}/v1/status/{job_id}",
                           headers={"Authorization": f"Bearer {key}"})
        res.raise_for_status()
        job = res.json()
        if job["status"] in ("completed", "failed"):
            return job
        if time.monotonic() > deadline:
            raise TimeoutError(job_id)
        time.sleep(1.5)

GET /v1/jobs/{job_id}/mask

GET/v1/jobs/{job_id}/maskfree

Download the PNG mask for a completed job. Returns 409 while the job is still queued or processing, and 404 if the job belongs to another account or has expired.

Sample call

curl https://api.floorplanapi.com/v1/jobs/job_abc123/mask \
  -H "Authorization: Bearer fp_test_..." \
  --output walls.png

Success response

HTTP/1.1 200 OK
Content-Type: image/png
X-Mask-Width: 1024
X-Mask-Height: 768
X-Job-Id: job_abc123

<binary PNG bytes>

Response headers

HeaderDescription
X-Request-IdUnique ID for the request — include in support tickets.
X-Job-IdJob ID created for the request. Use it to poll /v1/status/{id} after a 504.
X-Mask-WidthPixel width of the returned PNG (matches the input image).
X-Mask-HeightPixel height of the returned PNG.
X-Floorplan-Modelive or test.
X-RateLimit-LimitRequests per minute allowed.
X-RateLimit-RemainingRequests left in current window.
X-RateLimit-ResetUnix timestamp when the window resets.

Errors

Error responses always use Content-Type: application/json with a stable shape, even on endpoints whose success responses are PNG bytes. The Python client maps these onto typed exceptions.

{
  "error": {
    "type": "authentication_error",
    "message": "Invalid API key."
  }
}
StatustypePython exceptionWhen
400invalid_request_errorInvalidRequestErrorMissing field, malformed body.
401authentication_errorAuthenticationErrorMissing/invalid/revoked/expired key.
403authentication_errorAuthenticationErrorResource doesn't belong to your account.
404not_found_errorNotFoundErrorJob ID not found or expired.
409invalid_request_errorInvalidRequestErrorMask requested before the job completed.
413invalid_request_errorInvalidRequestErrorImage larger than 10 MB.
415invalid_request_errorInvalidRequestErrorUnsupported MIME type.
429rate_limit_errorRateLimitErrorPer-minute rate limit exceeded.
503service_unavailableServerErrorQueue at capacity; honour Retry-After.
504timeout_errorServerErrorSync request did not finish in time; poll /v1/status/{job_id}.
5xxserver_errorServerErrorTransient server problem; retried automatically.

Rate limits & quotas

Rate limits are per API key per minute. Hitting the limit returns 429 with a Retry-After header. Monthly credit budgets apply separately and reset on the first day of the billing period.

PlanRequests/minuteConcurrentMonthly credits
Free10150
Starter303100
Pro6010500
EnterpriseCustomCustomUnlimited

Support

Have a question, found a bug, or want to ship something we don't expose yet?

  • Contact us — for everything else.
  • Include the X-Request-Id response header (or the request_id field on the FloorPlanError in Python) to speed things up.
  • Dashboard — manage keys, view usage, change plan.