floorplan-api
Official Python client for the Floor Plan API: upload a floor plan, get back a binary wall-segmentation PNG mask.
- Image in, image out. PNG, JPEG, WEBP, or one page of a PDF. The mask
comes back as PNG bytes at the input's resolution:
255= wall,0= everything else. - Two clients, one surface.
Client(synchronous, onrequests) andAsyncClient(asyncio, onhttpx). - Retries that don't double-bill. Transient failures are retried with backoff; a job the server already queued is never resubmitted.
- Works with paths, raw bytes, or binary file-like objects. Large files go straight to object storage via a presigned URL.
- Self-hosted friendly: point
base_urlat any Floor Plan API deployment.
Install
pip install floorplan-api # sync client (requests + pypdf)
pip install "floorplan-api[async]" # adds AsyncClient (httpx)
Python 3.9+.
Quickstart
from floorplan_api import Client
client = Client(api_key="fp_test_...") # or set FLOORPLAN_API_KEY
mask = client.extract("plans/floor1.png") # PNG, JPEG or WEBP
mask = client.extract("plans/set.pdf", page=3) # PDF: pick the page
with open("walls.png", "wb") as fh:
fh.write(mask)
print(mask.width, mask.height, mask.job_id)
extract() returns MaskBytes, a bytes subclass. Write it, hash it, hand
it to Pillow or OpenCV as usual; the extra attributes width, height,
job_id, request_id and mode (live/test) come from the response
headers. For a PDF, page_size_pt and pdf_scale are attached too (see
below). The client never post-processes the mask.
Any of these inputs work:
client.extract("plans/floor1.png") # path string
client.extract(Path("plans/floor1.pdf")) # pathlib.Path
client.extract(image_bytes) # raw bytes (format is sniffed)
client.extract(open("plan.jpg", "rb")) # binary file-like
The format is detected from the file's leading bytes (PNG, JPEG, WEBP, PDF
signatures), falling back to the extension. Anything else raises
InvalidRequestError before a request is made.
How your file is sent
Be aware that images and PDFs are handled differently on the way out:
| Input | What is uploaded |
|---|---|
| PNG, JPEG, WEBP | The file, byte for byte. The client never decodes, resizes or re-encodes an image. |
A new single-page PDF containing only the requested page. Built locally with pypdf: the page object is copied with its content stream, resources (fonts, embedded images) and annotations; nothing is rasterised client-side. The other pages, document metadata, bookmarks, attachments and form definitions are not sent. |
|
upload_key |
Nothing; the object is already in storage. For a multi-page PDF you stored yourself, page= is sent as a form field and the server picks the page. |
So a 40-page drawing set costs one page of bandwidth and storage, and the
server only ever holds the page you asked about. If you need the whole
document on the server side, upload it with upload() from a tool that
does not slice, then call extract(upload_key=..., page=N).
PDFs and page
A PDF is processed one page at a time. Pass page= (1-based; default 1) to
say which. Page errors (out of range, password-protected, unreadable) are
raised as InvalidRequestError before anything is sent. The API rasterises
the page at 200 DPI (longest edge capped at 8192 px) and returns the mask
at that size; read it from mask.width and mask.height.
mask = client.extract("set.pdf", page=3)
key = client.upload("set.pdf", page=3) # the stored object is page 3 only
mask = client.extract(upload_key=key) # ... so no page is needed here
page on a raster input is rejected unless it is 1. When you submit by
upload_key for an object you stored yourself (raw REST), page= is sent
to the server, which renders that page of the stored file.
Mapping the mask back to PDF coordinates. The mask is on the rendered
page's pixel grid, not in PDF points. The client measures the page's crop
box (honouring /Rotate) before upload and attaches it, so:
mask = client.extract("set.pdf", page=3)
mask.page_size_pt # (1728.0, 2592.0) -> a 24 x 36 in sheet
mask.pdf_scale # mask pixels per PDF point: mask.width / page width
mask.pdf_dpi # the DPI actually used: 200, or less if the page hit the 8192 px cap
x_px = x_pt * mask.pdf_scale # PDF point -> mask pixel (origin: top-left of the render)
floorplan_api.pdf_page_size(data, page) gives the same (width, height)
in points for any PDF, for example to compute the scale for a mask you
fetched later with download_mask(), which has no page_size_pt.
Async
import asyncio
from floorplan_api import AsyncClient
async def main() -> None:
async with AsyncClient() as client:
masks = await asyncio.gather(
client.extract("a.pdf"),
client.extract("b.png"),
)
for m in masks:
print(m.size)
asyncio.run(main())
AsyncClient has the same methods as Client, all awaitable. Pass your own
httpx.AsyncClient as client= for proxies or HTTP/2; the wrapper then
leaves it open.
Large files
Inline uploads are capped at 10 MB. upload_then_extract() switches to a
presigned upload for anything bigger, so the API server never holds the
bytes:
mask = client.upload_then_extract("big_floor_plan.pdf", page=2)
# or step by step:
key = client.upload("big_floor_plan.pdf", page=2) # PUT straight to object storage
mask = client.extract(upload_key=key) # submit by storage key
The size check happens after the page is cut out, so a large multi-page PDF whose selected page is small still takes the inline path.
analyze() and analyze_async() accept upload_key= the same way.
What the API does with your file
Nothing on the client or the API server touches pixels; the worker does,
like this (the full trace is in docs/IMAGE_PIPELINE.md of the API repo):
- Rasters are decoded with OpenCV in colour mode. Alpha is dropped without compositing, so flatten transparent PNGs onto white first; grayscale is expanded to three channels; 16-bit depth becomes 8-bit; JPEG EXIF orientation is applied, so the mask aligns with the displayed orientation; ICC profiles are ignored. A raster whose longer edge exceeds 8192 px is processed downscaled to that bound and the mask is resized back to the input size, so it stays pixel-aligned but carries less detail.
- PDF pages are rendered at 200 DPI onto white (transparent regions
composite onto white), reduced so the longer edge is at most 8192 px. All
content is rendered: linework, hatching, text, dimensions. The mask has
the rendered size, reported in
width/height. - Inference is a two-stage U-Net++ (whole sheet at shortest side 1024, then a crop refiner at native resolution). No test-time augmentation.
- Output is
prob > 0.5as an 8-bit single-channel PNG with values exactly 0 and 255, no morphology or filtering.255is wall in the carved convention: door and window openings are not wall, and walls are as thick as the source linework.
Limits you will meet: inline uploads 10 MB; presigned URLs valid 15 min;
beta and Free keys 10 requests per minute; the sync endpoints wait 30 s for
the worker before answering 504 with the job id; the queue answers 503 with
Retry-After: 30 when 100 jobs are pending. extract costs 1 credit,
analyze 2, only on live keys.
Authentication
API keys come from the Floor Plan API dashboard.
- Live keys (
fp_live_...) — production use, billed against your account. - Test keys (
fp_test_...) — same model, never billed.
client = Client(api_key="fp_live_xxx")
# or, equivalently:
import os; os.environ["FLOORPLAN_API_KEY"] = "fp_live_xxx"
client = Client()
Background jobs
For batches, submit a job and collect the mask later:
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, timeout=300.0)
if final.status == "completed":
mask = client.download_mask(final.id)
print(f"got {final.result.width}x{final.result.height} mask")
# Or poll yourself:
job = client.get_job(job.id)
if job.is_terminal:
...
analyze and extract currently produce identical output; the two
endpoints are kept distinct so future tiers can attach to analyze
without breaking extract's simpler contract.
Timeouts on a busy queue
The synchronous endpoints wait about 30 s for the worker. If the queue is
deep the server answers 504 and includes the job's id; the job keeps
running. The client raises TimeoutError with job_id set and does not
retry (a retry would queue a second copy). Finish the job without
resubmitting:
from floorplan_api import TimeoutError
try:
mask = client.extract("plan.pdf")
except TimeoutError as exc:
if exc.job_id is None:
raise # client-side timeout
job = client.wait_for_job(exc.job_id)
mask = client.download_mask(job.id)
Errors
All errors derive from FloorPlanError. Catch the base class to handle every
API error, or specific subclasses to take action:
from floorplan_api import (
Client, FloorPlanError,
AuthenticationError, RateLimitError, InvalidRequestError, NotFoundError,
ServerError, ProcessingError, TimeoutError, ConnectionError,
)
try:
mask = client.extract("plan.png")
except RateLimitError as exc:
time.sleep(exc.retry_after or 5.0)
except ProcessingError as exc:
print(f"worker could not process this file: {exc.message} (job {exc.job_id})")
except AuthenticationError:
print("Check your API key.")
except FloorPlanError as exc:
print(f"{exc.type}: {exc.message} (request_id={exc.request_id})")
| Exception | Status | When | Retried |
|---|---|---|---|
AuthenticationError |
401, 403 | Missing/invalid/expired/revoked key; job belongs to another account | no |
InvalidRequestError |
400, 409, 413, 415 | Malformed body, bad page, mask requested before completion, file too large, unsupported type. Also raised locally for unsupported input or a PDF page that does not exist |
no |
NotFoundError |
404 | Job/resource missing | no |
RateLimitError |
429 | Per-minute rate limit exceeded | yes, honouring Retry-After |
TimeoutError |
504 | Worker did not finish in the sync window; job_id set |
no |
ProcessingError |
500 | Worker failed the job (undecodable file, ...); job_id set |
no |
ServerError |
other 5xx | Outage, queue at capacity (503 honours Retry-After) |
yes |
TimeoutError |
— | Client-side timeout exceeded, or wait_for_job gave up |
connection timeouts yes |
ConnectionError |
— | DNS / TCP / TLS failure | yes |
Every exception carries status_code, type, request_id, job_id and
the decoded response body when available.
Configuration
client = Client(
api_key="fp_live_...",
base_url="https://api.floorplanapi.com", # default
timeout=60.0, # seconds per request
max_retries=3, # connection errors, 429, transient 5xx
retry_backoff=0.5, # base delay (s) for exp backoff w/ jitter
session=None, # your own requests.Session
)
AsyncClient takes the same arguments, with client= (an
httpx.AsyncClient) in place of session=.
A server Retry-After header overrides the backoff, capped at 30 s per
attempt. With the defaults, a 503 "queue at capacity" response can hold an
extract() call for up to about 90 s before it raises.
Environment variables:
FLOORPLAN_API_KEY— used whenapi_key=is omitted.FLOORPLAN_BASE_URL— used whenbase_url=is omitted (handy for self-hosted).
Pointing at a self-hosted instance
client = Client(
api_key="fp_test_...",
base_url="http://localhost:3000",
)
The Next.js app rewrites /v1/* to /api/v1/* internally, so the
client's base URL is the bare host with no /api segment.
Examples
See examples/:
quickstart.py— extract a single image or PDFasync_client.py— extract several files concurrentlyasync_job.py— submit + poll + downloadsync_timeout_recovery.py— finish a job after a 504local_dev.py— talk to a local dev server
Development
pip install -e '.[dev]'
pytest
ruff check src tests examples
mypy src
Releases: bump src/floorplan_api/_version.py, add a changelog entry, and
push a python-v<version> tag. CI runs the tests on Python 3.9–3.13,
builds the sdist and wheel, and publishes to PyPI via trusted publishing.
License
MIT — see LICENSE.
Release files for floorplan-api 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| floorplan_api-0.5.0.tar.gz | 36.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| floorplan_api-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 66.0 kB
Release files / floorplan_api-0.5.0.tar.gz
| Download URL | floorplan_api-0.5.0.tar.gz |
|---|---|
| Size | 36.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d14fdbfbb5a77f0ff1b1fe1c5aae4d5ccd923a0428200c5a2c7f7615cde77fc6
|
|
BLAKE2b-256 checksum How to use checksums |
9b33df29244e12bf876bd06e1007a94df6caf40573885df0afbdbb120bc626e3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.
Transparency logRelease files / floorplan_api-0.5.0-py3-none-any.whl
| Download URL | floorplan_api-0.5.0-py3-none-any.whl |
|---|---|
| Size | 29.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9e14caf8ba6ba049bff2d3f09cda3436313e68d78027387f7ae1de76bfe40a50
|
|
BLAKE2b-256 checksum How to use checksums |
b6b01406617d3ae0415de0ff94080924c9c42ba032495dd00f022e8235712978
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.
Transparency log