floorplan-api 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/CHANGELOG.md +41 -0
  2. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/PKG-INFO +125 -59
  3. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/README.md +123 -57
  4. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/examples/async_client.py +7 -3
  5. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/examples/async_job.py +6 -5
  6. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/examples/local_dev.py +4 -3
  7. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/examples/quickstart.py +16 -4
  8. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/examples/sync_timeout_recovery.py +6 -5
  9. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/pyproject.toml +1 -1
  10. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/src/floorplan_api/__init__.py +9 -5
  11. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/src/floorplan_api/_input.py +2 -2
  12. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/src/floorplan_api/_transport.py +26 -6
  13. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/src/floorplan_api/_version.py +1 -1
  14. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/src/floorplan_api/async_client.py +41 -23
  15. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/src/floorplan_api/client.py +80 -43
  16. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/src/floorplan_api/exceptions.py +3 -3
  17. floorplan_api-0.6.0/src/floorplan_api/models.py +517 -0
  18. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/tests/conftest.py +64 -4
  19. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/tests/test_async_client.py +116 -26
  20. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/tests/test_client.py +228 -63
  21. floorplan_api-0.6.0/tests/test_models.py +310 -0
  22. floorplan_api-0.5.0/src/floorplan_api/models.py +0 -241
  23. floorplan_api-0.5.0/tests/test_models.py +0 -143
  24. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/.gitignore +0 -0
  25. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/LICENSE +0 -0
  26. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/src/floorplan_api/py.typed +0 -0
  27. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/tests/__init__.py +0 -0
  28. {floorplan_api-0.5.0 → floorplan_api-0.6.0}/tests/test_input.py +0 -0
@@ -5,6 +5,47 @@ All notable changes to `floorplan-api` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0] - 2026-10-02
9
+
10
+ ### Changed (breaking)
11
+ - **`extract`, `analyze` and `upload_then_extract` return a `Result`**
12
+ instead of `MaskBytes` (sync and async clients). One call now gives the
13
+ wall mask and the structured result:
14
+ - `result.wall_mask`: the PNG wall mask, still a `MaskBytes` (`bytes`
15
+ with `width`, `height`, `job_id`, `request_id`, `mode`, `page_size_pt`,
16
+ `pdf_scale`, `pdf_dpi`). Code that wrote the return value to a file now
17
+ writes `result.wall_mask`.
18
+ - `result.room_mask`: PNG bytes where each pixel holds a room `id`
19
+ (0 = no room), or `None`.
20
+ - `result.detections`: doors and windows as `Detection` (`label`,
21
+ `confidence`, `polygon`, `center`, `size`); `result.doors` and
22
+ `result.windows` filter by label. `label` is the JSON `class` key.
23
+ - `result.rooms`: `Room` objects (`id`, `score`, `area`, `polygon`, plus
24
+ `outline` and `holes`).
25
+ - `result.counts`, `width`, `height`, `page`, `dpi`, `min_room_score`,
26
+ `job_id`, `request_id`, `mode`, `page_size_pt`, and `raw` (the JSON
27
+ response without the masks).
28
+
29
+ All coordinates are pixels on the wall mask's grid.
30
+ - **`download_mask(job_id)` is replaced by `get_result(job_id)`**, which
31
+ returns the same `Result` for a completed job. There is no alias. After a
32
+ `TimeoutError` with `job_id`, recover with `wait_for_job` + `get_result`.
33
+ - `Job.result.result_url` now points at the job's result
34
+ (`/v1/jobs/{job_id}/result`).
35
+ - `MaskBytes.from_response` is replaced by `MaskBytes.from_result`: mask
36
+ `width` / `height` now come from the response body; the `X-Mask-Width` /
37
+ `X-Mask-Height` headers are no longer sent.
38
+ - A successful response without a usable wall mask raises `ServerError`.
39
+
40
+ ### Added
41
+ - `Result`, `Detection` and `Room` are exported from `floorplan_api`.
42
+ - **`min_room_score=`** on `extract`, `analyze` and `analyze_async` (sync and
43
+ async clients): the room-score floor, a number from 0 to 0.9 (server
44
+ default 0.9). Every room carries a `score`: 1.0 for a fully enclosed room;
45
+ lower values for rooms the service had to close itself. Rooms scoring
46
+ below `min_room_score` are not returned. Values outside 0–0.9 raise
47
+ `InvalidRequestError` before any request.
48
+
8
49
  ## [0.5.0] - 2026-09-25
9
50
 
10
51
  ### Added
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: floorplan-api
3
- Version: 0.5.0
4
- Summary: Official Python client for the Floor Plan API — wall-segmentation masks from floor plan images and PDFs.
3
+ Version: 0.6.0
4
+ Summary: Official Python client for the Floor Plan API — walls, doors, windows and rooms from floor plan images and PDFs.
5
5
  Project-URL: Homepage, https://floorplanapi.com
6
6
  Project-URL: Documentation, https://floorplanapi.com/docs
7
7
  Project-URL: Changelog, https://floorplanapi.com/docs#python-changelog
@@ -44,11 +44,12 @@ Description-Content-Type: text/markdown
44
44
  # floorplan-api
45
45
 
46
46
  Official Python client for the [Floor Plan API](https://floorplanapi.com):
47
- upload a floor plan, get back a binary wall-segmentation PNG mask.
47
+ upload a floor plan, get back its walls, doors, windows and rooms.
48
48
 
49
- * **Image in, image out.** PNG, JPEG, WEBP, or one page of a PDF. The mask
50
- comes back as PNG bytes at the input's resolution: `255` = wall, `0` =
51
- everything else.
49
+ * **One call, everything.** PNG, JPEG, WEBP, or one page of a PDF in; a
50
+ `Result` out: the wall mask as PNG bytes at the input's resolution
51
+ (`255` = wall, `0` = everything else), the doors and windows as oriented
52
+ boxes, and the rooms as polygons plus a room mask.
52
53
  * **Two clients, one surface.** `Client` (synchronous, on `requests`) and
53
54
  `AsyncClient` (asyncio, on `httpx`).
54
55
  * **Retries that don't double-bill.** Transient failures are retried with
@@ -72,20 +73,83 @@ Python 3.9+.
72
73
  from floorplan_api import Client
73
74
 
74
75
  client = Client(api_key="fp_test_...") # or set FLOORPLAN_API_KEY
75
- mask = client.extract("plans/floor1.png") # PNG, JPEG or WEBP
76
- mask = client.extract("plans/set.pdf", page=3) # PDF: pick the page
76
+ result = client.extract("plans/floor1.png") # PNG, JPEG or WEBP
77
+ result = client.extract("plans/set.pdf", page=3) # PDF: pick the page
77
78
 
78
79
  with open("walls.png", "wb") as fh:
79
- fh.write(mask)
80
+ fh.write(result.wall_mask) # the PNG wall mask
80
81
 
81
- print(mask.width, mask.height, mask.job_id)
82
+ print(result.width, result.height, result.job_id)
83
+ print(len(result.rooms), "rooms,", len(result.doors), "doors,", len(result.windows), "windows")
82
84
  ```
83
85
 
84
- `extract()` returns `MaskBytes`, a `bytes` subclass. Write it, hash it, hand
85
- it to Pillow or OpenCV as usual; the extra attributes `width`, `height`,
86
- `job_id`, `request_id` and `mode` (`live`/`test`) come from the response
87
- headers. For a PDF, `page_size_pt` and `pdf_scale` are attached too (see
88
- below). The client never post-processes the mask.
86
+ ## The result
87
+
88
+ `extract()`, `analyze()`, `upload_then_extract()` and `get_result()` return a
89
+ `Result`. Everything in it shares one pixel grid: the decoded input image,
90
+ or the rendered PDF page. The origin is the top-left corner.
91
+
92
+ | Attribute | What it is |
93
+ | --- | --- |
94
+ | `wall_mask` | The wall mask: PNG bytes, 8-bit single-channel, `255` = wall, `0` = everything else. A `MaskBytes` (see below). |
95
+ | `room_mask` | PNG bytes where each pixel holds the `id` of its room (`0` = no room); 8-bit, or 16-bit when there are 255 or more rooms. `None` when the service produced no rooms output. |
96
+ | `detections` | Doors and windows, a list of `Detection`. `result.doors` and `result.windows` are the same list filtered by label. |
97
+ | `rooms` | A list of `Room`. |
98
+ | `counts` | Items per kind, e.g. `{"door": 12, "window": 11, "room": 11}`. May be empty. |
99
+ | `width`, `height`, `size` | The pixel grid. |
100
+ | `page`, `dpi` | The PDF page that was processed and the resolution it was rendered at. `dpi` is `None` for raster input. |
101
+ | `min_room_score` | The room-score floor that was applied (or `None`). |
102
+ | `job_id`, `request_id`, `mode` | The job, the request ID for support tickets, and `live`/`test`. |
103
+ | `page_size_pt` | For a PDF you passed as a file: the page size in PDF points (see below). |
104
+ | `raw` | The decoded JSON response without the two base64 masks, ready for `json.dump`. |
105
+
106
+ ```python
107
+ for door in result.doors:
108
+ door.label # "door" or "window" (the JSON key is `class`)
109
+ door.confidence # 0 to 1
110
+ door.polygon # the 4 corners of the oriented box: [(x, y), ...]
111
+ door.center # (x, y)
112
+ door.size # (width, height) along the box's own axes
113
+
114
+ for room in result.rooms:
115
+ room.id # 1, 2, ...: the pixel value of this room in result.room_mask
116
+ room.score # 1.0 for a fully enclosed room; lower values for rooms
117
+ # the service had to close itself
118
+ room.area # in square pixels
119
+ room.outline # the outer ring: [(x, y), ...], no closing point
120
+ room.holes # rings cut out of the room
121
+ room.polygon # [outline, *holes]
122
+ ```
123
+
124
+ `result.wall_mask` is a `MaskBytes`, a `bytes` subclass. Write it, hash it,
125
+ hand it to any image library as usual; it also carries `width`, `height`,
126
+ `job_id`, `request_id`, `mode` and, for a PDF, `page_size_pt` and
127
+ `pdf_scale`. The client never post-processes either mask.
128
+
129
+ ```python
130
+ import io, json
131
+ import numpy as np
132
+ from PIL import Image
133
+
134
+ walls = np.array(Image.open(io.BytesIO(result.wall_mask))) # 0 / 255
135
+ if result.room_mask is not None:
136
+ room_ids = np.array(Image.open(io.BytesIO(result.room_mask))) # 0 = no room
137
+ kitchen = room_ids == result.rooms[0].id
138
+
139
+ json.dump(result.raw, open("plan.json", "w")) # everything but the masks
140
+ ```
141
+
142
+ ### Choosing which rooms you get
143
+
144
+ Every room carries a `score`: 1.0 for a fully enclosed room; lower values
145
+ for rooms the service had to close itself. `min_room_score=` (0 to 0.9,
146
+ server default 0.9) sets the floor: rooms scoring below it are not returned.
147
+
148
+ ```python
149
+ result = client.extract("plan.png", min_room_score=0.5) # a lower floor returns more rooms
150
+ ```
151
+
152
+ ## Inputs
89
153
 
90
154
  Any of these inputs work:
91
155
 
@@ -120,35 +184,38 @@ does not slice, then call `extract(upload_key=..., page=N)`.
120
184
  A PDF is processed one page at a time. Pass `page=` (1-based; default 1) to
121
185
  say which. Page errors (out of range, password-protected, unreadable) are
122
186
  raised as `InvalidRequestError` before anything is sent. The API rasterises
123
- the page at 200 DPI (longest edge capped at 8192 px) and returns the mask
124
- at that size; read it from `mask.width` and `mask.height`.
187
+ the page at 200 DPI (longest edge capped at 8192 px) and returns the result
188
+ on that grid; read it from `result.width`, `result.height` and `result.dpi`.
125
189
 
126
190
  ```python
127
- mask = client.extract("set.pdf", page=3)
191
+ result = client.extract("set.pdf", page=3)
128
192
  key = client.upload("set.pdf", page=3) # the stored object is page 3 only
129
- mask = client.extract(upload_key=key) # ... so no page is needed here
193
+ result = client.extract(upload_key=key) # ... so no page is needed here
130
194
  ```
131
195
 
132
196
  `page` on a raster input is rejected unless it is 1. When you submit by
133
197
  `upload_key` for an object you stored yourself (raw REST), `page=` is sent
134
198
  to the server, which renders that page of the stored file.
135
199
 
136
- **Mapping the mask back to PDF coordinates.** The mask is on the rendered
137
- page's pixel grid, not in PDF points. The client measures the page's crop
138
- box (honouring `/Rotate`) before upload and attaches it, so:
200
+ **Mapping the result back to PDF coordinates.** The masks, detections and
201
+ rooms are on the rendered page's pixel grid, not in PDF points. The client
202
+ measures the page's crop box (honouring `/Rotate`) before upload and
203
+ attaches it, so:
139
204
 
140
205
  ```python
141
- mask = client.extract("set.pdf", page=3)
142
- mask.page_size_pt # (1728.0, 2592.0) -> a 24 x 36 in sheet
143
- mask.pdf_scale # mask pixels per PDF point: mask.width / page width
206
+ result = client.extract("set.pdf", page=3)
207
+ result.page_size_pt # (1728.0, 2592.0) -> a 24 x 36 in sheet
208
+ mask = result.wall_mask
209
+ mask.pdf_scale # pixels per PDF point: mask.width / page width
144
210
  mask.pdf_dpi # the DPI actually used: 200, or less if the page hit the 8192 px cap
145
211
 
146
- x_px = x_pt * mask.pdf_scale # PDF point -> mask pixel (origin: top-left of the render)
212
+ x_px = x_pt * mask.pdf_scale # PDF point -> pixel (origin: top-left of the render)
213
+ outline_pt = [(x / mask.pdf_scale, y / mask.pdf_scale) for x, y in result.rooms[0].outline]
147
214
  ```
148
215
 
149
216
  `floorplan_api.pdf_page_size(data, page)` gives the same `(width, height)`
150
- in points for any PDF, for example to compute the scale for a mask you
151
- fetched later with `download_mask()`, which has no `page_size_pt`.
217
+ in points for any PDF, for example to compute the scale for a result you
218
+ fetched later with `get_result()`, which has no `page_size_pt`.
152
219
 
153
220
  ### Async
154
221
 
@@ -158,12 +225,12 @@ from floorplan_api import AsyncClient
158
225
 
159
226
  async def main() -> None:
160
227
  async with AsyncClient() as client:
161
- masks = await asyncio.gather(
228
+ results = await asyncio.gather(
162
229
  client.extract("a.pdf"),
163
230
  client.extract("b.png"),
164
231
  )
165
- for m in masks:
166
- print(m.size)
232
+ for r in results:
233
+ print(r.size, len(r.rooms))
167
234
 
168
235
  asyncio.run(main())
169
236
  ```
@@ -179,11 +246,11 @@ presigned upload for anything bigger, so the API server never holds the
179
246
  bytes:
180
247
 
181
248
  ```python
182
- mask = client.upload_then_extract("big_floor_plan.pdf", page=2)
249
+ result = client.upload_then_extract("big_floor_plan.pdf", page=2)
183
250
 
184
251
  # or step by step:
185
252
  key = client.upload("big_floor_plan.pdf", page=2) # PUT straight to object storage
186
- mask = client.extract(upload_key=key) # submit by storage key
253
+ result = client.extract(upload_key=key) # submit by storage key
187
254
  ```
188
255
 
189
256
  The size check happens after the page is cut out, so a large multi-page PDF
@@ -193,26 +260,24 @@ whose selected page is small still takes the inline path.
193
260
 
194
261
  ## What the API does with your file
195
262
 
196
- Nothing on the client or the API server touches pixels; the worker does,
197
- like this (the full trace is in `docs/IMAGE_PIPELINE.md` of the API repo):
263
+ The client never touches pixels. This is how the service reads your file
264
+ and what the result is aligned to:
198
265
 
199
- * **Rasters** are decoded with OpenCV in colour mode. Alpha is dropped
200
- without compositing, so flatten transparent PNGs onto white first;
201
- grayscale is expanded to three channels; 16-bit depth becomes 8-bit; JPEG
202
- EXIF orientation is applied, so the mask aligns with the *displayed*
203
- orientation; ICC profiles are ignored. A raster whose longer edge exceeds
204
- 8192 px is processed downscaled to that bound and the mask is resized back
205
- to the input size, so it stays pixel-aligned but carries less detail.
266
+ * **Rasters.** Alpha is dropped without compositing, so flatten transparent
267
+ PNGs onto white first; grayscale is expanded to three channels; 16-bit
268
+ depth becomes 8-bit; JPEG EXIF orientation is applied, so the result
269
+ aligns with the *displayed* orientation; ICC profiles are ignored. A
270
+ raster whose longer edge exceeds 8192 px is processed downscaled to that
271
+ bound and the result is mapped back to the input size, so it stays
272
+ pixel-aligned but carries less detail.
206
273
  * **PDF pages** are rendered at 200 DPI onto white (transparent regions
207
274
  composite onto white), reduced so the longer edge is at most 8192 px. All
208
- content is rendered: linework, hatching, text, dimensions. The mask has
209
- the rendered size, reported in `width`/`height`.
210
- * **Inference** is a two-stage U-Net++ (whole sheet at shortest side 1024,
211
- then a crop refiner at native resolution). No test-time augmentation.
212
- * **Output** is `prob > 0.5` as an 8-bit single-channel PNG with values
213
- exactly 0 and 255, no morphology or filtering. `255` is wall in the
214
- *carved* convention: door and window openings are not wall, and walls are
215
- as thick as the source linework.
275
+ content is rendered: linework, hatching, text, dimensions. The result is
276
+ on the rendered grid, reported in `width`/`height`/`dpi`.
277
+ * **Wall mask.** An 8-bit single-channel PNG with values exactly 0 and 255.
278
+ `255` is wall in the *carved* convention: door and window openings are
279
+ not wall, and walls are as thick as the source linework.
280
+ * **Doors, windows and rooms** are on the same pixel grid as the wall mask.
216
281
 
217
282
  Limits you will meet: inline uploads 10 MB; presigned URLs valid 15 min;
218
283
  beta and Free keys 10 requests per minute; the sync endpoints wait 30 s for
@@ -236,7 +301,7 @@ client = Client()
236
301
 
237
302
  ## Background jobs
238
303
 
239
- For batches, submit a job and collect the mask later:
304
+ For batches, submit a job and collect the result later:
240
305
 
241
306
  ```python
242
307
  job = client.analyze_async("plan.png")
@@ -244,8 +309,8 @@ print(f"Submitted {job.id}, status={job.status}")
244
309
 
245
310
  final = client.wait_for_job(job.id, poll_interval=2.0, timeout=300.0)
246
311
  if final.status == "completed":
247
- mask = client.download_mask(final.id)
248
- print(f"got {final.result.width}x{final.result.height} mask")
312
+ result = client.get_result(final.id)
313
+ print(f"got {result.width}x{result.height}, {len(result.rooms)} rooms")
249
314
 
250
315
  # Or poll yourself:
251
316
  job = client.get_job(job.id)
@@ -255,7 +320,7 @@ if job.is_terminal:
255
320
 
256
321
  `analyze` and `extract` currently produce identical output; the two
257
322
  endpoints are kept distinct so future tiers can attach to `analyze`
258
- without breaking `extract`'s simpler contract.
323
+ without changing `extract`.
259
324
 
260
325
  ## Timeouts on a busy queue
261
326
 
@@ -269,12 +334,12 @@ resubmitting:
269
334
  from floorplan_api import TimeoutError
270
335
 
271
336
  try:
272
- mask = client.extract("plan.pdf")
337
+ result = client.extract("plan.pdf")
273
338
  except TimeoutError as exc:
274
339
  if exc.job_id is None:
275
340
  raise # client-side timeout
276
341
  job = client.wait_for_job(exc.job_id)
277
- mask = client.download_mask(job.id)
342
+ result = client.get_result(job.id)
278
343
  ```
279
344
 
280
345
  ## Errors
@@ -290,7 +355,7 @@ from floorplan_api import (
290
355
  )
291
356
 
292
357
  try:
293
- mask = client.extract("plan.png")
358
+ result = client.extract("plan.png")
294
359
  except RateLimitError as exc:
295
360
  time.sleep(exc.retry_after or 5.0)
296
361
  except ProcessingError as exc:
@@ -304,12 +369,13 @@ except FloorPlanError as exc:
304
369
  | Exception | Status | When | Retried |
305
370
  | --- | --- | --- | --- |
306
371
  | `AuthenticationError` | 401, 403 | Missing/invalid/expired/revoked key; job belongs to another account | no |
307
- | `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 |
372
+ | `InvalidRequestError` | 400, 409, 413, 415 | Malformed body, bad `page`, result requested before completion, file too large, unsupported type. Also raised locally for unsupported input or a PDF page that does not exist | no |
308
373
  | `NotFoundError` | 404 | Job/resource missing | no |
309
374
  | `RateLimitError` | 429 | Per-minute rate limit exceeded | yes, honouring `Retry-After` |
310
375
  | `TimeoutError` | 504 | Worker did not finish in the sync window; `job_id` set | no |
311
376
  | `ProcessingError` | 500 | Worker failed the job (undecodable file, ...); `job_id` set | no |
312
377
  | `ServerError` | other 5xx | Outage, queue at capacity (503 honours `Retry-After`) | yes |
378
+ | `ServerError` | — | A successful response that does not carry a usable result (no wall mask) | no |
313
379
  | `TimeoutError` | — | Client-side `timeout` exceeded, or `wait_for_job` gave up | connection timeouts yes |
314
380
  | `ConnectionError` | — | DNS / TCP / TLS failure | yes |
315
381
 
@@ -359,7 +425,7 @@ See [`examples/`](./examples/):
359
425
 
360
426
  * [`quickstart.py`](examples/quickstart.py) — extract a single image or PDF
361
427
  * [`async_client.py`](examples/async_client.py) — extract several files concurrently
362
- * [`async_job.py`](examples/async_job.py) — submit + poll + download
428
+ * [`async_job.py`](examples/async_job.py) — submit + poll + fetch the result
363
429
  * [`sync_timeout_recovery.py`](examples/sync_timeout_recovery.py) — finish a job after a 504
364
430
  * [`local_dev.py`](examples/local_dev.py) — talk to a local dev server
365
431