Metadata-Version: 2.5
Name: floorplan-api
Version: 0.5.0
Summary: Official Python client for the Floor Plan API — wall-segmentation masks from floor plan images and PDFs.
Project-URL: Homepage, https://floorplanapi.com
Project-URL: Documentation, https://floorplanapi.com/docs
Project-URL: Changelog, https://floorplanapi.com/docs#python-changelog
Author-email: Floor Plan API <admin@auctas.ai>
License: MIT
License-File: LICENSE
Keywords: api,computer vision,extraction,floor plan,floorplan,segmentation
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Graphics
Classifier: Topic :: Scientific/Engineering :: Image Recognition
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: pypdf>=4.0
Requires-Dist: requests>=2.28
Provides-Extra: async
Requires-Dist: httpx>=0.24; extra == 'async'
Provides-Extra: dev
Requires-Dist: httpx>=0.24; extra == 'dev'
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: responses>=0.23; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Requires-Dist: types-requests; extra == 'dev'
Description-Content-Type: text/markdown

# floorplan-api

Official Python client for the [Floor Plan API](https://floorplanapi.com):
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, on `requests`) and
  `AsyncClient` (asyncio, on `httpx`).
* **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_url` at any Floor Plan API deployment.

## Install

```bash
pip install floorplan-api              # sync client (requests + pypdf)
pip install "floorplan-api[async]"     # adds AsyncClient (httpx)
```

Python 3.9+.

## Quickstart

```python
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:

```python
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. |
| PDF | **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`.

```python
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:

```python
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

```python
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:

```python
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.5` as an 8-bit single-channel PNG with values
  exactly 0 and 255, no morphology or filtering. `255` is 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](https://floorplanapi.com/api-keys).

* **Live keys** (`fp_live_...`) — production use, billed against your account.
* **Test keys** (`fp_test_...`) — same model, never billed.

```python
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:

```python
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:

```python
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:

```python
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

```python
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 when `api_key=` is omitted.
* `FLOORPLAN_BASE_URL` — used when `base_url=` is omitted (handy for self-hosted).

## Pointing at a self-hosted instance

```python
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/`](./examples/):

* [`quickstart.py`](examples/quickstart.py) — extract a single image or PDF
* [`async_client.py`](examples/async_client.py) — extract several files concurrently
* [`async_job.py`](examples/async_job.py) — submit + poll + download
* [`sync_timeout_recovery.py`](examples/sync_timeout_recovery.py) — finish a job after a 504
* [`local_dev.py`](examples/local_dev.py) — talk to a local dev server

## Development

```bash
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](./LICENSE).
