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.
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_def456Mask 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.
| Property | Value |
|---|---|
| format | PNG |
| channels | 1 (single-channel grayscale) |
| bit depth | 8 |
| pixel values | 0 = background; 255 = wall. No greys — the mask is binarised at the API boundary. |
| resolution | Identical to the input image. The mask aligns pixel-for-pixel with the image you uploaded. |
| colour space | None — 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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxfp_live_. Charged against your account, write usage records.fp_test_. Run the same model, never billed, never logged as usage.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-apiOnly 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)},
).contentAsync jobs
For batches or longer-running analyses, submit a job and poll for completion, then download the mask. Three calls cover the full lifecycle:
| Step | Method | Returns |
|---|---|---|
| 1. Submit | analyze_async(image) | Job (status="queued") |
| 2. Wait or poll | wait_for_job(job.id) / get_job(id) | Job (terminal) |
| 3. Download | download_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 bitmapError 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})")
raiseServer 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
| Argument | Default | Description |
|---|---|---|
| api_key | env FLOORPLAN_API_KEY | Your API key. |
| base_url | https://api.floorplanapi.com | Override for self-hosted deployments. Also reads FLOORPLAN_BASE_URL. |
| timeout | 60.0 | Per-request timeout (seconds). Override per call with the keyword arg. |
| max_retries | 3 | Retries for connection errors, 5xx, and 429. |
| retry_backoff | 0.5 | Base delay (s) for exponential backoff with jitter. |
| session | new | Reuse a custom requests.Session. |
Method reference
| Method | Returns | REST equivalent |
|---|---|---|
| extract(image, *, timeout=None) | bytes | POST /v1/extract |
| analyze(image, *, timeout=None) | bytes | POST /v1/analyze |
| upload_then_extract(image, *, threshold_bytes=10_485_760, timeout=None) | bytes | POST /v1/uploads + PUT + POST /v1/extract |
| create_upload(*, content_type="image/png", timeout=None) | dict | POST /v1/uploads |
| upload(image, *, content_type=None, timeout=None) | str (storage key) | POST /v1/uploads + PUT |
| analyze_async(image, *, timeout=None) | Job | POST /v1/analyze?async=true |
| get_job(job_id, *, timeout=None) | Job | GET /v1/status/{id} |
| wait_for_job(job_id, *, poll_interval=1.0, timeout=300.0) | Job | GET /v1/status/{id} (loop) |
| download_mask(job_id, *, timeout=None) | bytes | GET /v1/jobs/{id}/mask |
| close() | None | Closes 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.comLocal development:
http://localhost:3000Authorization header
Authorization: Bearer fp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxPOST /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.pngResponse
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-Typedeclared in the request body, or it will be rejected. - The returned
keyis 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.pngSample 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/invalidimagefield.401— missing / invalid / revoked / expired key.413— image larger than 10 MB on the inline path; use POST /v1/uploads +upload_keyinstead.415— unsupported MIME type.429— rate limit exceeded.503— queue full; honourRetry-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
imageorupload_key(required) — same semantics as /v1/extract. async(defaultfalse) — set totrueto submit asynchronously.
Sync call
curl -X POST https://api.floorplanapi.com/v1/analyze \
-H "Authorization: Bearer fp_test_..." \
-F "image=@plan.png" \
--output walls.pngAsync 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.pngSuccess 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
| Header | Description |
|---|---|
| X-Request-Id | Unique ID for the request — include in support tickets. |
| X-Job-Id | Job ID created for the request. Use it to poll /v1/status/{id} after a 504. |
| X-Mask-Width | Pixel width of the returned PNG (matches the input image). |
| X-Mask-Height | Pixel height of the returned PNG. |
| X-Floorplan-Mode | live or test. |
| X-RateLimit-Limit | Requests per minute allowed. |
| X-RateLimit-Remaining | Requests left in current window. |
| X-RateLimit-Reset | Unix 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."
}
}| Status | type | Python exception | When |
|---|---|---|---|
| 400 | invalid_request_error | InvalidRequestError | Missing field, malformed body. |
| 401 | authentication_error | AuthenticationError | Missing/invalid/revoked/expired key. |
| 403 | authentication_error | AuthenticationError | Resource doesn't belong to your account. |
| 404 | not_found_error | NotFoundError | Job ID not found or expired. |
| 409 | invalid_request_error | InvalidRequestError | Mask requested before the job completed. |
| 413 | invalid_request_error | InvalidRequestError | Image larger than 10 MB. |
| 415 | invalid_request_error | InvalidRequestError | Unsupported MIME type. |
| 429 | rate_limit_error | RateLimitError | Per-minute rate limit exceeded. |
| 503 | service_unavailable | ServerError | Queue at capacity; honour Retry-After. |
| 504 | timeout_error | ServerError | Sync request did not finish in time; poll /v1/status/{job_id}. |
| 5xx | server_error | ServerError | Transient 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.
| Plan | Requests/minute | Concurrent | Monthly credits |
|---|---|---|---|
| Free | 10 | 1 | 50 |
| Starter | 30 | 3 | 100 |
| Pro | 60 | 10 | 500 |
| Enterprise | Custom | Custom | Unlimited |
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-Idresponse header (or therequest_idfield on theFloorPlanErrorin Python) to speed things up. - Dashboard — manage keys, view usage, change plan.