diff --git a/README.md b/README.md
index 50b7474..fe059fc 100644
--- a/README.md
+++ b/README.md
@@ -154,7 +154,7 @@ Detected surfaces appear as numbered outlines over the scan. In the editor:
| Move a corner | Drag a handle. On a curved run, the neighboring points follow with a smooth falloff, so the curve bends instead of kinking. |
| Add a corner | Double-click an edge of the selected surface. |
| Remove a corner | Alt-click a handle. A surface always keeps at least three corners. |
-| Skip part of the room when scanning | Camera section: **Mask area**, then click corners on the camera preview around what to scan, double-click to finish; add more areas as needed. Windows, TVs and shiny things outside the mask are ignored by the next scan. **Hole fill** sets how much small gaps in the scan are filled (more: fuller surfaces; less: truer edges). **HDR** captures each pattern at 2 or 3 exposures and keeps the best-lit reading per pixel, for scenes with dark and bright surfaces (slower). All are kept per camera. |
+| Skip part of the room when scanning | Camera section: **Skip an area**, then click corners on the camera preview around a window, a TV or shiny things, double-click to finish; add more areas as needed. Skipped areas are shaded on the preview and left out of the next scan; click one to stop skipping it, or **Skip nothing** to clear them all. **Hole fill** sets how much small gaps in the scan are filled (more: fuller surfaces; less: truer edges). **HDR** captures each pattern at 2 or 3 exposures and keeps the best-lit reading per pixel, for scenes with dark and bright surfaces (slower). All are kept per camera. |
| Zoom in to place points precisely | Pinch on the trackpad (or Cmd/Ctrl + scroll) over the scan to zoom, up to 16×; two-finger scroll moves around. **−**, **+** and **Fit** sit under the scan. Handles and labels keep their size, so you can place points much more precisely. |
| Less on screen | Click a sidebar heading to fold or open that section; the Editor remembers it. Set-up-once sections (Schedule, MIDI, Hardware, Camera, Test frame) start folded, and a section with a problem (no projector, sound blocked) opens by itself. |
| Delete surfaces | Click a surface and press **Delete** or **Backspace** (or the panel's Delete). Shift-click more surfaces to delete them all at once; one Undo brings them back. |
diff --git a/docs/http-api.md b/docs/http-api.md
index e3c0735..5a9eb8d 100644
--- a/docs/http-api.md
+++ b/docs/http-api.md
@@ -23,7 +23,7 @@ Most write routes respond with the updated show, and the show is broadcast over
| GET | `/api/camera/preview.jpg` | | A JPEG frame, downscaled to 1280 px wide. 409 if no camera is selected or a scan is running |
| POST | `/api/camera/release` | | Closes the camera. 409 while scanning |
| GET | `/api/camera/scan-settings` | | The selected camera's scan settings: `{"hole_fill": 9, "hdr": 1, "mask": null}`. 404 with no camera |
-| POST | `/api/camera/scan-settings` | `{"hole_fill"?: 0..31, "hdr"?: 1..3, "mask"?: [[[x, y], ...], ...], "clear_mask"?: true}` | Changes them for the selected camera (saved with its calibration in settings.json); the next scan uses them. `hole_fill`: how wide a gap between decoded pixels is filled (0 = none). `mask`: areas of the camera image to scan (up to 16 shapes of 3+ points, 0..1 camera coordinates); everything outside is ignored. `clear_mask`: scan the whole image again. `hdr`: 1 (off), 2 or 3 exposures per pattern, the calibrated one plus longer ones (x4; x3 and x9) capped at the camera's limit, merged per pixel |
+| POST | `/api/camera/scan-settings` | `{"hole_fill"?: 0..31, "hdr"?: 1..3, "mask"?: [[[x, y], ...], ...], "clear_mask"?: true}` | Changes them for the selected camera (saved with its calibration in settings.json); the next scan uses them. `hole_fill`: how wide a gap between decoded pixels is filled (0 = none). `mask`: areas of the camera image to skip, e.g. a window, a TV or shiny things (up to 16 shapes of 3+ points, 0..1 camera coordinates); everything else is scanned. `clear_mask`: skip nothing again. `hdr`: 1 (off), 2 or 3 exposures per pattern, the calibrated one plus longer ones (x4; x3 and x9) capped at the camera's limit, merged per pixel |
| POST | `/api/camera/calibrate` | | Shows white on the output and runs the exposure search; returns `{"exposure", "gain", "p99"}`. 409 without a USB webcam or output window, 422 if calibration fails |
| POST | `/api/camera/framing` | | Shows white then black on the output (acknowledged, like scan patterns) and returns how the projection fills the camera's view: `{"span": 0..1, "cut_off": bool, "outline": [[x, y], ...] (0..1), "advice": str or null}`. The projector goes back to the show after. 409 without a camera or output window, or while scanning |
diff --git a/engine/messages.py b/engine/messages.py
index e90e02b..0355e5a 100644
--- a/engine/messages.py
+++ b/engine/messages.py
@@ -108,6 +108,7 @@ class OscRequest(BaseModel):
class ScanSettingsRequest(BaseModel):
# What each means, its default and bounds: engine/scan_settings.py.
hole_fill: Annotated[int, Field(ge=0, le=HOLE_FILL_MAX)] | None = None
+ # Areas of the camera image to skip (0..1 coordinates), drawn on the preview (#170).
mask: Annotated[list[Annotated[list[UnitPoint], Field(min_length=3, max_length=MASK_MAX_POINTS)]],
Field(min_length=1, max_length=MASK_MAX_AREAS)] | None = None
clear_mask: bool = False # back to scanning the whole camera image
diff --git a/engine/scan.py b/engine/scan.py
index ee06a6b..efc6a47 100644
--- a/engine/scan.py
+++ b/engine/scan.py
@@ -206,19 +206,18 @@ def result(self) -> DecodeResult:
def apply_camera_mask(r: DecodeResult, polygons: list[list[list[float]]] | None) -> DecodeResult:
- """Keeps only camera pixels inside the mask (#66): polygons in 0..1 camera coordinates, e.g.
- drawn on the preview to skip a window or a TV. None or [] keeps everything."""
+ """Drops camera pixels inside the skipped areas (#66, #170): polygons in 0..1 camera coordinates,
+ drawn on the preview around a window, a TV or shiny things. None or [] skips nothing."""
import cv2
if not polygons:
return r
h, w = r.valid.shape
- keep = np.zeros((h, w), np.uint8)
+ skip = np.zeros((h, w), np.uint8)
for poly in polygons:
pts = np.round(np.array(poly, np.float64) * [w, h]).astype(np.int32)
- cv2.fillPoly(keep, [pts], 1)
- inside = keep.astype(bool)
- r.valid = r.valid & inside
+ cv2.fillPoly(skip, [pts], 1)
+ r.valid = r.valid & ~skip.astype(bool)
r.proj_x = np.where(r.valid, r.proj_x, -1)
r.proj_y = np.where(r.valid, r.proj_y, -1)
return r
diff --git a/engine/scan_settings.py b/engine/scan_settings.py
index 904fb23..4abcf45 100644
--- a/engine/scan_settings.py
+++ b/engine/scan_settings.py
@@ -17,7 +17,7 @@
class ScanSettings:
hole_fill: int = 9 # px gap between decoded pixels the scan fills in (0 = none)
hdr: int = 1 # exposures per pattern: 1 = off
- mask: list | None = None # areas of the camera image to scan (0..1 coordinates); None = all of it
+ mask: list | None = None # areas of the camera image to skip (0..1 coordinates); None = scan it all
aperture: str = "8" # still cameras: the f-number to scan at (f/8: deep focus), or "camera": as set
@classmethod
diff --git a/frontend/index.html b/frontend/index.html
index e26c749..451dd97 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -88,7 +88,8 @@
#framing-overlay { pointer-events: none; }
#framing-overlay polygon { fill: none; stroke: #38bdf8; stroke-width: 2; stroke-dasharray: 6 4; vector-effect: non-scaling-stroke; }
#framing-note.warn { color: #fbbf24; }
- #mask-overlay polygon { fill: rgba(74, 222, 128, 0.15); stroke: #4ade80; stroke-width: 2; vector-effect: non-scaling-stroke; }
+ /* Skipped areas: shaded, so the preview shows what the scan will leave out (#170); click one to remove it. */
+ #mask-overlay polygon { fill: rgba(239, 68, 68, 0.35); stroke: #ef4444; stroke-width: 2; vector-effect: non-scaling-stroke; cursor: pointer; }
#mask-overlay polyline { fill: none; stroke: #fbbf24; stroke-width: 2; vector-effect: non-scaling-stroke; }
#preview-box.drawing { cursor: crosshair; }
details.fold-inline { margin: 0 0 12px; }
@@ -333,9 +334,9 @@
Surface
-
-
-
+
+
+
diff --git a/frontend/src/editor/cameraPanel.browser.test.ts b/frontend/src/editor/cameraPanel.browser.test.ts
index 86b92d8..8cb1647 100644
--- a/frontend/src/editor/cameraPanel.browser.test.ts
+++ b/frontend/src/editor/cameraPanel.browser.test.ts
@@ -148,4 +148,46 @@ describe("the Camera section", () => {
await vi.waitFor(() => expect(notices).toEqual([`Exposure check failed: ${detail}`]));
expect($("calibrate").textContent).toBe("Check exposure");
});
+
+ it("says drawn areas are skipped (#170)", async () => {
+ const twoAreas = [[[0, 0], [0.2, 0], [0.2, 0.2]], [[0.5, 0.5], [0.7, 0.5], [0.7, 0.7]]];
+ const { engine } = fakeEngine({ scanSettings: async () => json({ hole_fill: 9, hdr: 1, mask: twoAreas, aperture: "8" }) });
+ const panel = mountCameraPanel({ engine, notice: () => {} });
+ expect($("mask-draw").textContent).toBe("Skip an area");
+ await panel.loadScanSettings();
+ expect($("mask-note").textContent).toBe("Skipping 2 areas");
+ expect($("mask-draw").title + $("mask-draw").closest(".row")!.getAttribute("title")).toMatch(/skip/i);
+ });
+
+ it("removes one skipped area when it's clicked, keeping the others", async () => {
+ const twoAreas = [[[0, 0], [0.2, 0], [0.2, 0.2]], [[0.5, 0.5], [0.7, 0.5], [0.7, 0.7]]];
+ const { engine, calls } = fakeEngine({ scanSettings: async () => json({ hole_fill: 9, hdr: 1, mask: twoAreas, aperture: "8" }) });
+ const panel = mountCameraPanel({ engine, notice: () => {} });
+ await panel.loadScanSettings();
+ document.querySelectorAll("#mask-overlay polygon")[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ expect(calls).toContain(`setScanSettings ${JSON.stringify({ mask: [twoAreas[1]] })}`);
+ });
+
+ it("shows the preview after a framing check, so its outline can be seen (#170)", async () => {
+ const { engine } = fakeEngine();
+ const panel = mountCameraPanel({ engine, notice: () => {}, fetchPreview: async () => new Response(new Blob(["x"])) });
+ panel.update(status(AC410), false);
+ expect($("preview-box").hidden).toBe(true);
+ $("check-framing").click();
+ await vi.waitFor(() => expect($("framing-note").hidden).toBe(false));
+ expect($("preview-box").hidden).toBe(false);
+ panel.stopPreview();
+ });
+
+ it("forgets the framing result when another camera is chosen", async () => {
+ const { engine } = fakeEngine();
+ const panel = mountCameraPanel({ engine, notice: () => {} });
+ panel.update(status(AC410), false);
+ $("check-framing").click();
+ await vi.waitFor(() => expect($("framing-note").hidden).toBe(false));
+ panel.update(status(A6600), false);
+ expect($("framing-note").hidden).toBe(true);
+ expect(document.querySelector("#framing-overlay polygon")!.getAttribute("points")).toBe("");
+ });
});
+
diff --git a/frontend/src/editor/cameraPanel.ts b/frontend/src/editor/cameraPanel.ts
index d4885b1..8b2b113 100644
--- a/frontend/src/editor/cameraPanel.ts
+++ b/frontend/src/editor/cameraPanel.ts
@@ -57,16 +57,23 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel {
holeFill.addEventListener("input", () => { holeFillReadout.textContent = `${holeFill.value} px`; });
holeFill.addEventListener("change", () => void engine.setScanSettings({ hole_fill: Number(holeFill.value) }));
- // ---- Camera mask (#66): click corners on the preview to mark areas to scan; the rest is ignored.
+ // ---- Skipped areas (#66, #170): click corners on the preview around what the scan should leave out
+ // (a window, a TV, shiny things); click a shaded area to stop skipping it.
const previewBox = $("preview-box");
const maskOverlay = $("mask-overlay");
let maskDraw = idleDraw; // in preview pixels, so the double-click echo rule works; saved as 0..1
function renderMask() {
const { width, height } = previewBox.getBoundingClientRect();
const toUnit = (p: number[]) => `${p[0] / Math.max(1, width)},${p[1] / Math.max(1, height)}`;
- const shapes: SVGElement[] = (cameraMask ?? []).map((poly) => {
+ const shapes: SVGElement[] = (cameraMask ?? []).map((poly, i) => {
const el = document.createElementNS(SVG_NS, "polygon");
el.setAttribute("points", poly.map(([x, y]) => `${x},${y}`).join(" "));
+ el.addEventListener("click", (ev) => {
+ if (maskDraw.active) return; // drawing: the click places a corner
+ ev.stopPropagation();
+ const rest = (cameraMask ?? []).filter((_, j) => j !== i);
+ void engine.setScanSettings(rest.length ? { mask: rest } : { clear_mask: true }).then(() => loadScanSettings());
+ });
return el;
});
if (maskDraw.active && maskDraw.points.length) {
@@ -76,9 +83,10 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel {
}
maskOverlay.replaceChildren(...shapes);
previewBox.classList.toggle("drawing", maskDraw.active);
- $("mask-draw").textContent = maskDraw.active ? "Double-click to finish" : "Mask area";
+ $("mask-draw").textContent = maskDraw.active ? "Double-click to finish" : "Skip an area";
$("mask-clear").hidden = !cameraMask;
- $("mask-note").textContent = cameraMask ? `Scanning ${cameraMask.length} area${cameraMask.length > 1 ? "s" : ""} only` : "";
+ $("mask-note").textContent = maskDraw.active ? "Click corners on the preview around what to skip"
+ : cameraMask ? `Skipping ${cameraMask.length} area${cameraMask.length > 1 ? "s" : ""}` : "";
}
function maskStep(ev: DrawEvent) {
maskDraw = drawStep(maskDraw, ev);
@@ -183,6 +191,7 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel {
framingNote.classList.toggle("warn", view.warn);
framingNote.hidden = false;
document.querySelector("#framing-overlay polygon")!.setAttribute("points", view.points);
+ if (!previewRunning()) togglePreview(); // the outline is drawn on the preview (#170)
}));
// ---- What status says about the camera.
@@ -206,6 +215,10 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel {
return {
update(next, isScanning) {
+ if (next?.camera.selected !== status?.camera.selected) { // another camera: its framing is unknown
+ framingNote.hidden = true;
+ document.querySelector("#framing-overlay polygon")!.setAttribute("points", "");
+ }
status = next;
scanning = isScanning;
render();
diff --git a/frontend/src/shared/messages.ts b/frontend/src/shared/messages.ts
index 94aa22d..9dd82fc 100644
--- a/frontend/src/shared/messages.ts
+++ b/frontend/src/shared/messages.ts
@@ -267,7 +267,7 @@ export function parseServerMessage(raw: string): ServerMessage | null {
export interface ScanSettingsResponse {
hole_fill: number; // px gap between decoded pixels the scan fills in
hdr: number; // exposures per pattern: 1 = off
- mask: number[][][] | null; // areas of the camera image to scan (0..1); null = all of it
+ mask: number[][][] | null; // areas of the camera image to skip (0..1); null = skip nothing
aperture: string; // still cameras: f-number ("8" = f/8), or "camera"
}
diff --git a/tests/test_scan_api.py b/tests/test_scan_api.py
index 150f205..2c9f4c6 100644
--- a/tests/test_scan_api.py
+++ b/tests/test_scan_api.py
@@ -211,7 +211,7 @@ def scan_covered(client, ed, out):
assert settings["scan_settings"][AC410["unique_id"]]["hole_fill"] == 25 # by camera, like calibration
-def test_a_camera_mask_limits_what_the_scan_finds(rig, tmp_path):
+def test_skipped_camera_areas_leave_out_what_they_cover(rig, tmp_path):
scene, uvc, hw, cams = rig
def scan(client, ed, out):
@@ -225,8 +225,8 @@ def scan(client, ed, out):
with engine(hw, data_dir=tmp_path, camera_factory=cams, uvc_factory=lambda a: uvc) as client, \
editor(client) as ed, output(client, W, H) as out:
full = scan(client, ed, out)
- left = [[[0, 0], [0.5, 0], [0.5, 1], [0, 1]]]
- assert client.post("/api/camera/scan-settings", json={"mask": left}).json()["mask"] == left
+ right = [[[0.5, 0], [1, 0], [1, 1], [0.5, 1]]] # skip the right half, where the box is (#170)
+ assert client.post("/api/camera/scan-settings", json={"mask": right}).json()["mask"] == right
masked = scan(client, ed, out)
assert masked["coverage"] < full["coverage"]
assert len(masked["surfaces"]) < len(full["surfaces"])
diff --git a/tests/test_scan_decode.py b/tests/test_scan_decode.py
index 84eb699..5a4e3e9 100644
--- a/tests/test_scan_decode.py
+++ b/tests/test_scan_decode.py
@@ -122,18 +122,19 @@ def test_hole_fill_closes_more_gaps_the_higher_it_is():
assert projector_space_image(r)[1].sum() == projector_space_image(r, hole_fill_px=9)[1].sum() # today's default
-def test_a_camera_mask_drops_everything_outside_it():
- """#66: only the masked area of the camera image is scanned; the rest yields nothing."""
+def test_skipped_areas_of_the_camera_image_yield_nothing():
+ """#170: drawn areas are skipped (a window, a TV, shiny things); everything else is scanned.
+ (Before, the drawn area was the only part kept, the opposite of what its button suggested.)"""
from engine.scan import apply_camera_mask, projector_space_image
from engine.surfaces import detect_surfaces
scene = Scene()
full = decode(scene)
- # Keep the left half of the camera image (0..1 coordinates), so the box (u 190..250) is outside.
- left = [[[0, 0], [0.5, 0], [0.5, 1], [0, 1]]]
- masked = apply_camera_mask(decode(scene), left)
- assert not masked.valid[:, 170:].any() # nothing decoded right of the mask's edge (160 px)
- assert masked.valid[:, :150].sum() == full.valid[:, :150].sum() # inside: untouched
+ # Skip the right half of the camera image (0..1 coordinates), so the box (u 190..250) is in it.
+ right = [[[0.5, 0], [1, 0], [1, 1], [0.5, 1]]]
+ masked = apply_camera_mask(decode(scene), right)
+ assert not masked.valid[:, 170:].any() # nothing decoded in the skipped area (from 160 px)
+ assert masked.valid[:, :150].sum() == full.valid[:, :150].sum() # outside it: untouched
assert (masked.proj_x[~masked.valid] == -1).all()
surfaces = detect_surfaces(masked, projector_space_image(masked))
assert len(surfaces) < len(detect_surfaces(full, projector_space_image(full))) # the box is gone