27 Commits

Author SHA1 Message Date
Ty b4832dbf09 fix(fe): build the title from the live figure selection
regenerateCopy() runs synchronously from a click handler, but it read the
figureName reactive, which Svelte has not flushed yet in that tick - so a
figure picked in the form never reached the title. Resolve the figure straight
from figIds at call time instead.
2026-09-25 21:16:47 -07:00
Ty 83b58c23a3 fix(fe): read batchApply as a sibling of results, not a child
onPhotosReady read results.batchApply, but on the measure capture state
batchApply is a SIBLING of results, so the read was always undefined and every
batch photo fell through to processedBlob: undefined. The Photos-ready lineup
showed all four PNGs (calibration, overlay, and both batch siblings) yet only
two images reached Woo, because publish.py selects WHERE processed_blob IS NOT
NULL. Read s.capture.batchApply.
2026-09-25 21:10:25 -07:00
Ty 350a849ab6 fix(fe): derive the title figure from the live selection
figureName read slab.figureTermIds (the stored prop) while regenerateCopy()
runs against the local figIds edit state, so a freshly picked figure never
reached the title - drafts published as 'Black Walnut Slab' instead of
'Quilted Black Walnut Slab'. Read the local selection instead.
2026-09-25 20:55:44 -07:00
Ty 104f29f5e3 fix(backend): clear a re-uploaded photo by id, not id+slab
slab_photos.id is a global primary key and photo ids are derived from the slab
id, so scoping the replace to the slab let a retried or concurrent upload of
the same draft hit UNIQUE(id) and 500. Clear the id unconditionally.
2026-09-25 20:41:00 -07:00
Ty dfe192fa8a fix(fe): make capture photo ids unique per slab
slab_photos.id is a global primary key, but photo ids were cap-<i>-<size> -
identical for the same photo set on a different slab, so the upload hit
UNIQUE constraint failed: slab_photos.id and returned 500. Key the id on the
slab id so it is unique across slabs.
2026-09-25 20:33:39 -07:00
Ty 23de433bdb fix(fe): attach the session's processed PNGs to the draft photos
onPhotosReady built the draft photos from the raw captured JPEGs and dropped
every PNG the session had already produced, so processedBlob was never set on
the M2 path (only the legacy PhotoImport path set it). photoUpload then omitted
the processed multipart field, slab_photos.processed_blob stayed NULL, and
publish.py - which selects WHERE processed_blob IS NOT NULL - uploaded zero
images to Woo. The verified SLAB-UAT-901 draft had images: 0.

Attach results.inventoryPng to the calibration photo and each successful
batchApply entry to its sibling, and add the dimension-overlay copy as its own
photo adjacent to the calibration one (REVIEW-5).
2026-09-25 20:23:29 -07:00
Ty e5afc7524b fix(fe): upload slab photos to the server before publish
ensureServerSlab sent calibration_photo_id at create, but slab_photos rows are
only created by POST /slabs/{id}/photos, which the frontend never called - so
the INSERT hit FOREIGN KEY (calibration_photo_id REFERENCES slab_photos(id))
and every create failed with a generic 400, blocking the whole create->publish
walk.

- stop sending calibration_photo_id at create
- upload every draft photo (original + processed PNG) right after the slab row
  exists, via new review/photoUpload.ts (multipart FormData, same error
  envelope so failures surface through fail(e))
- link calibration_photo_id in the existing review PUT, once the row exists
- extract the role rule into lineup.photoRole() so the review strip and the
  uploader cannot disagree
2026-09-25 12:14:35 -07:00
Ty 5c7e5c663e fix(backend): log create_slab driver errors server-side
QUALITY-120 keeps the driver stack out of the owner-facing response, but the
bare except logged nothing, so a failing create was undiagnosable in the field.
Log the exception server-side while keeping the generic owner message.
2026-09-25 12:04:53 -07:00
Ty fa54e113c3 fix(fe): match fig-/feat- tags by slug, and echo Figure selections to tags
Two fixes in the review form:
- figTags/featTags filtered on tag.name.startsWith('fig-'), but the live store
  marks the family on the SLUG (slug 'fig-chat', name 'Chatoyant'), so the
  figure/feature tag groups rendered empty. Match slug OR name.
- Echo Figure selections transparently: picking a Figure attribute term
  auto-selects the same-named fig-* tag and vice versa, so the Woo product
  carries the figure in both the pa_fig attribute and the fig-* tag without
  the owner picking it twice.
2026-09-25 11:56:28 -07:00
Ty bffbad8c4f fix(backend): keep fig-/feat- tags by slug, not just name
The live store marks the figure/feature tag families on the tag SLUG
(slug 'fig-chat', display name 'Chatoyant'), but _scoped_tags filtered on
name.startswith('fig-'), so all 11 fig-* tags were silently dropped at sync
and the figure-tag echo (METADATA-30) had nothing to draw from. Match the
prefix on slug OR name. Adds a test for the slug-prefixed bare-name shape.
2026-09-25 11:46:18 -07:00
Ty beab1b5bb7 fix(fe): case-insensitive attribute-name match in review form
attributeByName lowercased the store attribute name but compared it to the
raw lookup string, so 'figure' !== 'Figure' and Edge/Figure/Grade/Thickness/
Moisture all matched nothing - the Figure chip group (and the other attribute
pickers) rendered empty, so 'Mark Ready' could never enable against the live
store. Lowercase both sides so the synced attributes resolve.
2026-09-25 11:38:04 -07:00
Ty 1b32f71c91 fix(fe): axis preview reactive now depends on approvedMask
The axis-preview draw reactive only depended on the image being complete, so
it fired before approvedMask was populated (canvas stayed at the default
300x150 and nothing drew). Add cap.approvedMask to the reactive deps so the
sheet-removed photo + tape line render once the frozen mask is present.
2026-09-25 09:35:43 -07:00
Ty 039a5de960 fix(fe): show the sheet-removed image + length-axis line on the axis step
The axis step rendered only text and buttons - no image, so the owner had
nothing to confirm the length axis against and 'handing to step 4' felt like a
dead end. Render the calibration photo with the sheet checkerboarded (using the
frozen approvedMask) and draw the length-axis tape line in red from
axis.tapeLine.start to .end (source-pixel coords scaled to the canvas), so the
BG-removed slab and its axis are visible before approving.
2026-09-25 09:33:30 -07:00
Ty 564f03dcee feat(fe): keep only the largest slab component in BG removal
Small isolated artifacts (e.g. a room corner the photo caught past the black
sheet) survived the flood-fill as separate slab islands and carried into the
rest of the workflow. After the border flood-fill, label the 4-connected slab
components and keep only the largest, with a 0.5%-of-frame area floor that
also drops speckles. Adds keepLargestComponent + unit tests proving an
isolated blob and a corner artifact are dropped while the main slab is kept.
2026-09-25 09:26:52 -07:00
Ty 0bc86ecc6d fix(fe): debounce mask auto-run 500ms after slider/sheet changes
Rapid sensitivity drags triggered a flood of async mask runs; an in-flight
run could settle after the owner pressed Approve and clobber the state, so
the Approve click read a snapshot with overlay:null and early-returned.
Debounce 500ms after the user stops adjusting so the mask settles before any
interaction and the click reads a stable state.
2026-09-25 09:19:52 -07:00
Ty 703655a763 fix(fe): mask auto-run keyed on control, no settle loop
Key the auto-run off a controlKey (sheet:sensitivity) so it fires on mask
entry and on each sheet/sensitivity edit, but never re-fires when approveMask
settles the overlay - preventing both the empty-preview bug and an infinite
settle loop.
2026-09-25 09:15:20 -07:00
Ty e4a05dd468 fix(fe): auto-run the mask when the mask screen is active
The mask screen never populated its overlay: pickCalibration set overlay:null
and the only thing that computed it (approveMask) was gated behind the
Approve button, which is disabled until overlay.slabPx>0 - a chicken-and-egg,
so the preview stayed empty and Approve was permanently disabled. Auto-run
approveMask() whenever the mask screen is active (and on sheet/sensitivity
change) so the overlay and preview render immediately.
2026-09-25 09:11:54 -07:00
Ty 25c1f79fc1 fix(fe): downscale mask flood-fill to a working resolution
The border flood-fill on a full-res 2256x4000 (9M px) photo was too slow in
JS and the async mask pass timed out into the catch branch (overlay stayed
null, 'Mask settled' never rendered). Downscale to <=1200px for the fill,
then nearest-neighbor upsample the slab bitset back to full size so
minAreaRect/slabBounds stay in source-pixel coordinates.
2026-09-25 09:09:30 -07:00
Ty 1c58f12c5d feat(fe): replace threshold mask with client-side sheet BG removal (flood-fill)
The green/black-sheet luminance threshold could not separate the slab from
the 'black' sheet, which in real photos reads at lum ~50 and overlaps the
slab's bark/shadow tones (measured on the user's samples: 0% green, 60-80%
dark, no brightness gap) - it removed 100% of the image. Replace the mask
pass with connectivity-based background removal: sample the sheet tone from
the border, flood-fill from the border marking sheet-connected pixels as
background, keep the connected remainder as the slab. Robust to tonal
overlap. Adds bgRemoval.ts (removeBackground + sampleSheetTone) with unit
tests proving a sheet-toned slab pixel survives via enclosure.
2026-09-25 09:07:39 -07:00
Ty 673593c9b6 fix(fe): render the photo + mask overlay on the Mask step
The mask screen showed only controls and a pixel-count line - no photo, so
the owner had nothing to adjust the sheet/sensitivity against. Draw the
calibration photo to a canvas and paint removed (non-slab) pixels as a
light-gray checkerboard (CAPTURE-45/50), redrawing when the overlay settles.
Verified in Chromium: mask step now shows the photo with the checkerboard.
2026-09-25 08:26:20 -07:00
Ty b03939cf84 fix(fe): canvas fallback for decodeOrientedBitmap when createImageBitmap is absent
The LAN dev URL is a non-secure, sometimes restricted browser where
createImageBitmap may be undefined; the pick-calibration -> mask decode
would then reject. Fall back to a canvas + Image decode returning an
ImageBitmap. Contract unchanged (still returns ImageBitmap).
2026-09-25 08:03:10 -07:00
Ty 3321a7cfdf fix(fe): crypto.randomUUID not in non-secure contexts (LAN http) -> newId() fallback
The LAN dev URL (http://192.168.1.202) is not a secure context, so
crypto.randomUUID is undefined and adding a photo threw
'TypeError: crypto.randomUUID is not a function'. Add newId() (randomUUID
when available, else crypto.getRandomValues UUIDv4) and use it in draft()
and PhotoImport. Verified in Chromium.
2026-09-25 07:54:15 -07:00
Ty 7913e630d6 fix(fe): subscribe to capture-session store in components (was reading the store object)
`session.state` is a Svelte store; `$: state = session.state` bound the store
object (not its value), so state.capture was undefined and cap.k threw on
mount. Bind a local const to the store and use the $ prefix ($stateStore),
and use get() in App.onPhotosReady. Verified in Chromium: hero -> batch ->
sample uploads show thumbs.
2026-09-25 07:45:25 -07:00
Ty de2af5c6c7 fix(fe): hero 'Start capture (session)' enters the M2 session, not the chooser
The hero button called flow.set('capture') which renders the path-chooser
branch, so it stayed on the chooser and 'Choose photos' never appeared.
Point it at 'captureSession' so the hero action lands directly in the batch
screen. Verified in Chromium: hero button -> batch screen -> sample uploads
show thumbs.
2026-09-25 07:20:02 -07:00
Ty 88a2518cfa fix(fe): read capture-session store directly (no $ prefix on the object)
`createCaptureSession()` returns {state: Store, ...}; Svelte's $ prefix
calls .subscribe on the object, not the store, so $captureSession.state
threw 'subscribe is not a function' on App mount → black page. Reference the
store directly (auto-subscribes): captureSession.state / session.state /
session.lineup(). Verified: tsc clean, build green, 150/150 tests, and the
live app renders in Chromium.
2026-09-25 07:04:55 -07:00
Ty e09901b701 chore(fe): vite dev proxy for local FastAPI (dev-only, not in Docker build) 2026-09-25 07:01:20 -07:00
Ty 86091ed916 ops(greenfield): record isolated P5 deploy on ubudual5060 (10884/10885)
Standalone compose (project slab-greenfield) on ports 10884/10885 with
dedicated slab_greenfield_* volumes; live M3/M4 stacks (10881/10882/10883)
untouched. All smoke checks pass; Woo draft pending live creds (P5 exit).
2026-09-25 07:00:34 -07:00
19 changed files with 813 additions and 65 deletions
+4 -2
View File
@@ -59,8 +59,10 @@ async def upload_photo(
proc_bytes = await processed.read() if processed is not None else None
if not orig_bytes:
return JSONResponse(status_code=400, content=_error("validation", "original photo is empty"))
# Replace any existing photo with the same id on this slab (re-upload).
conn.execute("DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id))
# slab_photos.id is a global primary key and photo ids are derived from the
# slab id, so a re-upload must clear the id regardless of slab: a retried or
# concurrent upload of the same draft would otherwise hit UNIQUE(id).
conn.execute("DELETE FROM slab_photos WHERE id=?", (photo_id,))
seq_val = seq if seq is not None else _next_seq(conn, slab_id)
conn.execute(
"INSERT INTO slab_photos(id, slab_id, seq, is_calibration, role, original_blob, processed_blob, created_at) "
+7 -2
View File
@@ -6,6 +6,7 @@ sets deleted_at; published slabs cannot be deleted.
"""
import json
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List
@@ -15,6 +16,8 @@ from fastapi.responses import JSONResponse, Response
from .db import get_connection
from .thickness_band import load_thickness_terms, resolve_thickness_band_id
logger = logging.getLogger("slab.backend")
router = APIRouter(prefix="/api/v1/slabs")
# Client-settable slab columns (explicit allowlist; never build SQL from client keys).
@@ -224,8 +227,10 @@ async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
try:
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
conn.commit()
except Exception:
# QUALITY-120: never surface a driver stack to the owner.
except Exception as exc:
# QUALITY-120: never surface a driver stack to the owner, but log it
# server-side so a failing create is diagnosable.
logger.error("create_slab insert failed: %s", exc, exc_info=True)
return _err("validation", "Could not create the slab. Check the fields and try again.", status=400)
row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone()
return _row_to_slab(row)
+8 -1
View File
@@ -19,7 +19,14 @@ def _now_iso() -> str:
def _scoped_tags(tags: list[dict]) -> list[dict]:
return [t for t in tags if t.get("name", "").startswith(("fig-", "feat-"))]
# The fig-/feat- family marker is on the tag SLUG (e.g. slug 'fig-chat',
# name 'Chatoyant'), not the display name. Match either so a store that
# prefixes the name instead of the slug is also kept.
return [
t for t in tags
if (t.get("slug", "").startswith(("fig-", "feat-"))
or t.get("name", "").startswith(("fig-", "feat-")))
]
def _normalize_category(c: dict) -> tuple:
+18
View File
@@ -145,6 +145,24 @@ def test_sync_populates_scoped(db_dir):
assert conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()[0]
def test_sync_keeps_slug_prefixed_bare_name_tags(db_dir):
# Live-store shape: the fig- family marker is on the SLUG, the display name
# is bare (slug 'fig-chat', name 'Chatoyant'). These must be kept.
tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([
{"id": 300, "name": "Birdseye", "slug": "fig-brdseye", "woo_id": 300},
{"id": 310, "name": "Chatoyant", "slug": "fig-chat", "woo_id": 310},
{"id": 500, "name": "Knot", "slug": "feat-knot", "woo_id": 500},
{"id": 600, "name": "Rustic", "slug": "rustic", "woo_id": 600}, # bare non-fig: dropped
]).encode())
cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode())
attrs = (200, {"X-WP-TotalPages": "1"}, json.dumps([]).encode())
c, _ = _client([cats, tags, attrs])
taxonomy_mod.sync(c, db_mod.get_connection())
conn = db_mod.get_connection()
tag_ids = {r[0] for r in conn.execute("SELECT id FROM woo_tags")}
assert tag_ids == {300, 310, 500} # slug-prefixed fig/feat kept, bare 'rustic' dropped
def test_sync_failure_keeps_previous_cache(db_dir):
conn = db_mod.get_connection()
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (99,'Old','old',NULL,1)")
+26 -2
View File
@@ -115,5 +115,29 @@ Do not put a host name in the product spec.
- Live verification (loopback, real API): Continue to Review → `review-screen` visible,
heading "Review to Ready", no loadError, no pageerror. POST /slabs 201, photos 201×2,
GET slab/taxonomy/settings 200, review 200.
- In-repo mock e2e (m3-create + photo-strip) 6/6 green against the same bundle hash before deploy.
- Not done: commit/push of the import restore; Woo publish path not re-exercised this round.
- In-repo mock e2e (m3-create + photo-strip) 6/6 green against the same bundle hash before deploy.
- Not done: commit/push of the import restore; Woo publish path not re-exercised this round.
### 2026-09-25 14:00 PDT — greenfield rebuild M1–M3 live (isolated project)
- Source: branch `rewrite/greenfield-app` @ `49c22c9` "feat(greenfield): rebuild SlabUploader
M1-M3 on clean foundation, all gates green" (pushed to Gitea; pulled into `~/slab-deploy`).
`matches HEAD`: **yes** (deploy tree clean at `49c22c9`).
- Isolated deploy: **standalone** `deploy/greenfield-standalone.yml` +
`COMPOSE_PROJECT_NAME=slab-greenfield` (host-local, untracked). No base-file merge (avoids the
compose ports-merge collision on 10881/10882). Dedicated volumes `slab_greenfield_db` /
`slab_greenfield_images` — the live `deploy_slab_db` / `deploy_slab_images` (10881/10882)
are **untouched**.
- URLs: `http://192.168.1.202:10884` (LAN FE) · `http://100.64.0.9:10884` (Tailscale) ·
`http://192.168.1.202:10885` (LAN API) · loopback 127.0.0.1:18084 / 18085.
- `SLAB_ENV=dev` (mock-safe slice runs without live Woo creds at startup); real Woo creds are
entered in-app (Settings) for the P5 UAT walk. `WOO_BASE_URL` from `.env` (https, store).
- Smoke checks (§5) all pass: FE-served + direct `/api/health` →
`{"status":"ok","version":"0.6.0-gf-p3a","woo_reachable":false}`; fastapi log clean
(`schema ready (idempotent)`, `Application startup complete`, no refusal); FE serves
`<title>SlabUploader — Capture</title>`; `GET /settings` masked (SETTINGS-75);
`GET /admin/taxonomy` 200 (empty cache); 404 envelope `{"error":{...}}`.
- Existing stacks verified intact post-deploy: `deploy-frontend-1` (10881), `deploy-fastapi-1`
(10882), `slabuploader-m4-review` (10883), `slabuploader-m1` (18080).
- Not done: the live `SLAB-UAT-*` Woo draft (P5 exit) — needs Woo app password entered in
Settings + `test-woo` + the human UAT walk (QUALITY-105/30).
+50 -8
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
import { get, writable } from 'svelte/store';
import { apiFetch } from './lib/api';
import { uploadSlabPhotos } from './review/photoUpload';
import type { SlabDraft, Photo, Taxonomy } from './lib/types';
import { draft } from './lib/types';
import PhotoImport from './capture/PhotoImport.svelte';
@@ -52,7 +53,9 @@
}
// Register the capture result on the server so review/publish have a real
// slab id (POST /api/v1/slabs). Returns the stored slab.
// slab id (POST /api/v1/slabs). calibration_photo_id is NOT sent at create:
// the FK target row (slab_photos) does not exist yet — it is linked by PUT
// after the photos upload. Returns the stored slab.
async function ensureServerSlab(d: SlabDraft): Promise<SlabDraft> {
if (d.serverCreated) return d;
const created = await apiFetch<SlabDraft>('/slabs', {
@@ -67,9 +70,13 @@
width_min_in: d.widthMinIn,
width_max_in: d.widthMaxIn,
width_avg_in: d.widthAvgIn,
calibration_photo_id: d.calibrationPhotoId || null,
}),
});
// Photos must be on the server before publish (PUBLISH-40) and before the
// calibration link (FK). A photo failure surfaces through fail(e).
if (d.photos.length) {
await uploadSlabPhotos(d.id, d.photos, d.calibrationPhotoId);
}
return { ...d, serverCreated: true, clientRev: created.server_rev ?? d.clientRev + 1 };
}
@@ -98,7 +105,7 @@
const current = $slab;
try {
if (!current) {
const s = $captureSession.state;
const s = get(captureSession.state);
const results = s.capture.k === 'measure' ? s.capture.results : null;
const d = results
? draft({
@@ -111,17 +118,49 @@
widthAvgIn: results.widths.avg,
})
: draft();
// The session already produced the processed PNGs: the calibration
// inventory PNG in results.inventoryPng, and each sibling's in
// batchApply (a sibling of results on the measure state, parallel to
// s.batch). Attach them as processedBlob so the server upload carries
// real images (PUBLISH-40).
const calIdx = s.calibrationIndex;
const apply = s.capture.k === 'measure' ? s.capture.batchApply : null;
// slab_photos.id is a global primary key, so the id must be unique
// across slabs - key it on the slab id, not just the index/size.
const photos = s.batch.map((source, i) => ({
id: `cap-${i}-${source.blob.size}`,
id: `cap-${d.id}-${i}`,
seq: i + 1,
isCalibration: i === s.calibrationIndex,
isCalibration: i === calIdx,
url: URL.createObjectURL(source.blob),
blob: source.blob,
widthPx: source.widthPx,
heightPx: source.heightPx,
processedBlob:
i === calIdx
? results?.inventoryPng
: apply?.[i]?.status === 'ok'
? apply[i].inventoryPng
: undefined,
}));
// The dimension-overlay copy (REVIEW-5) is its own photo, adjacent to
// the calibration one and never flagged as it. Absent when the overlay
// encode failed (known separate defect) — the lineup just shows no overlay.
if (results?.overlayPng && calIdx != null) {
const overlay = {
id: `overlay-${d.id}-${calIdx}`,
seq: calIdx + 2,
isCalibration: false,
url: URL.createObjectURL(results.overlayPng),
blob: results.overlayPng,
widthPx: s.batch[calIdx]?.widthPx,
heightPx: s.batch[calIdx]?.heightPx,
processedBlob: results.overlayPng,
};
photos.splice(calIdx + 1, 0, overlay);
for (const p of photos) p.seq = photos.indexOf(p) + 1;
}
d.photos = photos;
d.calibrationPhotoId = s.calibrationIndex != null ? photos[s.calibrationIndex]?.id ?? '' : '';
d.calibrationPhotoId = calIdx != null ? photos[calIdx]?.id ?? '' : '';
slab.set(d);
await saveDraft(d);
}
@@ -189,6 +228,9 @@
short_description: next.shortDescription,
description: next.description,
content_source: next.description ? 'manual' : 'server',
// The calibration photo row now exists (uploaded in ensureServerSlab)
// — link it here so the FK target is in place before publish.
calibration_photo_id: next.calibrationPhotoId || null,
}),
});
next = { ...next, clientRev: (stored.server_rev as number) ?? next.clientRev + 1 };
@@ -244,7 +286,7 @@
<p>Capture • Measure • Review • Publish — real store API, SLAB-UAT-* publishes as draft</p>
<div class="actions">
<button on:click={startFresh} class="primary">Start new slab</button>
<button on:click={() => flow.set('capture')}>Start capture (session)</button>
<button on:click={() => flow.set('captureSession')}>Start capture (session)</button>
{#if $slab}
<button on:click={resume}>Resume draft</button>
{/if}
+2 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Photo } from '../lib/types';
import { newId } from '../lib/types';
import { keepForSheet } from '../mask/sheetKeep';
import { decodeOrientedBitmap } from './decodeOrientedBitmap';
@@ -75,7 +76,7 @@
if (!f.type.startsWith('image/')) { error = 'Only images'; return; }
const url = URL.createObjectURL(f);
previews.push({
id: crypto.randomUUID(),
id: newId(),
seq: i,
isCalibration: i === 0,
url,
+3 -2
View File
@@ -7,8 +7,9 @@
export let session: CaptureSession;
const dispatch = createEventDispatcher<{ continue: void }>();
$: state = $session.state;
$: items = $session.lineup();
const stateStore = session.state;
$: state = $stateStore;
$: items = session.lineup();
$: measure = state.capture.k === 'measure' ? state.capture : null;
$: results = measure?.results ?? null;
+150 -4
View File
@@ -6,7 +6,8 @@
export let session: CaptureSession;
const dispatch = createEventDispatcher<{ 'photos-ready': void }>();
$: state = $session.state;
const stateStore = session.state;
$: state = $stateStore;
$: cap = state.capture;
$: kind = cap.k;
@@ -17,8 +18,136 @@
$: measure = kind === 'measure' ? cap : null;
$: results = measure?.results ?? null;
// Mask preview: the calibration photo with removed (non-slab) pixels shown
// as a light-gray checkerboard (CAPTURE-45/50). Redrawn when the overlay
// settles. The photo element is bound so its natural size is available.
let maskCanvas: HTMLCanvasElement;
let maskImg: HTMLImageElement;
let maskObjUrl = '';
$: if (kind === 'mask' && cap.source) {
if (maskObjUrl) URL.revokeObjectURL(maskObjUrl);
maskObjUrl = URL.createObjectURL(cap.source.blob);
}
$: if (kind === 'mask' && maskImg?.complete && maskImg.naturalWidth > 0 && overlay) {
drawMaskPreview();
}
function drawMaskPreview() {
const canvas = maskCanvas;
if (!canvas || !maskImg || !overlay) return;
const w = maskImg.naturalWidth;
const h = maskImg.naturalHeight;
// Cap the canvas at a sensible size for performance; keep aspect.
const maxDim = 900;
const scale = Math.min(1, maxDim / Math.max(w, h));
canvas.width = Math.round(w * scale);
canvas.height = Math.round(h * scale);
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(maskImg, 0, 0, canvas.width, canvas.height);
// Downsample the full-res bitset to the canvas grid and checker removed px.
const bitset = overlay.bitset;
const cw = canvas.width;
const ch = canvas.height;
const cell = 6; // checker cell px
const imgData = ctx.getImageData(0, 0, cw, ch);
const px = imgData.data;
for (let y = 0; y < ch; y++) {
for (let x = 0; x < cw; x++) {
const sx = Math.min(w - 1, Math.round((x / cw) * w));
const sy = Math.min(h - 1, Math.round((y / ch) * h));
if (bitset[sy * w + sx]) continue; // slab pixel: keep photo
const checker = ((Math.floor(x / cell) + Math.floor(y / cell)) % 2 === 0);
const i = (y * cw + x) * 4;
const g = checker ? 210 : 235;
px[i] = g; px[i + 1] = g; px[i + 2] = g; px[i + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
}
// Axis preview: the sheet-removed photo with the length-axis tape line
// (red) drawn from axis.tapeLine.start to .end in source-pixel coords.
let axisCanvas: HTMLCanvasElement;
let axisImg: HTMLImageElement;
let axisObjUrl = '';
$: if (kind === 'axis' && cap.source) {
if (axisObjUrl) URL.revokeObjectURL(axisObjUrl);
axisObjUrl = URL.createObjectURL(cap.source.blob);
}
$: if (kind === 'axis' && cap.approvedMask && axisImg?.complete && axisImg.naturalWidth > 0) {
drawAxisPreview();
}
function drawAxisPreview() {
const canvas = axisCanvas;
if (!canvas || !axisImg || !cap.approvedMask) return;
const w = axisImg.naturalWidth;
const h = axisImg.naturalHeight;
const maxDim = 900;
const scale = Math.min(1, maxDim / Math.max(w, h));
canvas.width = Math.round(w * scale);
canvas.height = Math.round(h * scale);
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(axisImg, 0, 0, canvas.width, canvas.height);
// Checkerboard the removed (non-slab) pixels.
const bitset = cap.approvedMask.bitset;
const cw = canvas.width;
const ch = canvas.height;
const cell = 6;
const imgData = ctx.getImageData(0, 0, cw, ch);
const px = imgData.data;
for (let y = 0; y < ch; y++) {
for (let x = 0; x < cw; x++) {
const sx = Math.min(w - 1, Math.round((x / cw) * w));
const sy = Math.min(h - 1, Math.round((y / ch) * h));
if (bitset[sy * w + sx]) continue;
const checker = ((Math.floor(x / cell) + Math.floor(y / cell)) % 2 === 0);
const i = (y * cw + x) * 4;
const g = checker ? 210 : 235;
px[i] = g; px[i + 1] = g; px[i + 2] = g; px[i + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
// Draw the tape line (length axis) in red.
const [x1, y1] = cap.axis.tapeLine.start;
const [x2, y2] = cap.axis.tapeLine.end;
ctx.strokeStyle = '#ff3b30';
ctx.lineWidth = Math.max(2, canvas.width / 300);
ctx.beginPath();
ctx.moveTo((x1 / w) * cw, (y1 / h) * ch);
ctx.lineTo((x2 / w) * cw, (y2 / h) * ch);
ctx.stroke();
// Endpoint dots
ctx.fillStyle = '#ff3b30';
for (const [ex, ey] of [[x1, y1], [x2, y2]] as [number, number][]) {
ctx.beginPath();
ctx.arc((ex / w) * cw, (ey / h) * ch, Math.max(3, canvas.width / 150), 0, Math.PI * 2);
ctx.fill();
}
}
let fileInput: HTMLInputElement;
// Auto-run the mask so the overlay (and preview) populate without the owner
// pressing Approve first. Debounced 500ms after the user stops adjusting the
// sheet/sensitivity, so rapid slider drags don't trigger a flood of async
// mask runs (and an in-flight run can't clobber the state around an Approve
// click). Keyed on control values, not the overlay, so settling never loops.
let maskRunTimer = 0;
$: controlKey = kind === 'mask' && cap.control ? `${cap.control.sheet}:${cap.control.sensitivity}` : null;
$: if (controlKey && cap.source && cap.source.widthPx > 0) {
clearTimeout(maskRunTimer);
maskRunTimer = setTimeout(() => {
void session.approveMask();
}, 500);
}
function pickFiles(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files) return;
@@ -92,7 +221,13 @@
{:else if kind === 'mask'}
<h2>3. Mask</h2>
<p>Adjust sheet and sensitivity, then approve the mask.</p>
<p>Adjust sheet and sensitivity, then approve the mask. Removed background shows as a gray checkerboard.</p>
{#if cap.source && maskObjUrl}
<div class="mask-preview">
<canvas bind:this={maskCanvas} aria-label="mask preview"></canvas>
<img bind:this={maskImg} src={maskObjUrl} alt="calibration photo (hidden, drives mask preview)" class="mask-src" />
</div>
{/if}
{#if control}
<div class="controls">
<label>Sheet
@@ -107,7 +242,9 @@
</label>
</div>
{/if}
{#if failed}
{#if overlay && overlay.slabPx === 0}
<p class="err">No slab pixels detected — the mask removed everything. Lower the sensitivity, or set the sheet to match what the photo sits on, then retake if the shot is dark.</p>
{:else if failed}
<p class="err">No slab pixels detected. Adjust controls or retake.</p>
{:else if overlay}
<p>Mask settled: {overlay.slabPx} slab pixels.</p>
@@ -119,7 +256,13 @@
{:else if kind === 'axis'}
<h2>4. Length axis</h2>
<p>Place the tape endpoints at the two ends of the slab's length.</p>
<p>The sheet is removed. The red line marks the slab's length axis — confirm it runs end-to-end, then approve.</p>
{#if cap.source}
<div class="mask-preview">
<canvas bind:this={axisCanvas} aria-label="axis preview"></canvas>
<img bind:this={axisImg} src={axisObjUrl} alt="calibration photo (hidden, drives axis preview)" class="mask-src" />
</div>
{/if}
<div class="actions">
<button class="primary" on:click={() => session.approveAxis()}>Approve axis →</button>
<button on:click={() => session.back()}>← Back</button>
@@ -153,6 +296,9 @@
</section>
<style>
.mask-preview { margin: 1rem 0; }
.mask-preview canvas { width: 100%; height: auto; max-height: 70vh; object-fit: contain; display: block; border: 1px solid #444; background: #000; }
.mask-src { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.picker-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin: 1rem 0; color: #aaa; }
.picker { min-height: 44px; padding: 0.65rem 1rem; background: #00e5ff; color: #002; border: 0; font-weight: 700; border-radius: 4px; }
.file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
+40 -10
View File
@@ -4,24 +4,54 @@
*
* Matched-set / DJI JPEGs often carry EXIF Orientation 8 (stored landscape,
* display portrait). `new Image()` and bare `createImageBitmap(blob)` can
* disagree across browsers → axis arrow space ≠ mask bitset space.
* disagree across browsers -> axis arrow space != mask bitset space.
*
* Contract: always request `imageOrientation: 'from-image'`. If the runtime
* rejects that option (older engines), fall back to bare `createImageBitmap`;
* engines that already apply EXIF by default still match Image/canvas size.
* Contract: always request `imageOrientation: 'from-image'` when supported.
* The dev environment is LAN-HTTP (a non-secure, sometimes restricted
* browser) where `createImageBitmap` may be absent, so fall back to a
* canvas + Image decode that returns an ImageBitmap.
*/
export const ORIENTED_BITMAP_OPTIONS: ImageBitmapOptions = {
imageOrientation: 'from-image',
};
export async function decodeOrientedBitmap(blob: Blob): Promise<ImageBitmap> {
try {
return await createImageBitmap(blob, ORIENTED_BITMAP_OPTIONS);
} catch (first) {
if (typeof createImageBitmap === 'function') {
try {
return await createImageBitmap(blob);
} catch {
throw first;
return await createImageBitmap(blob, ORIENTED_BITMAP_OPTIONS);
} catch (first) {
try {
return await createImageBitmap(blob);
} catch {
throw first;
}
}
}
return decodeViaCanvas(blob);
}
/** Canvas + Image decode for engines without createImageBitmap. */
async function decodeViaCanvas(blob: Blob): Promise<ImageBitmap> {
const url = URL.createObjectURL(blob);
try {
const img = await loadImage(url);
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('2d context unavailable');
ctx.drawImage(img, 0, 0);
return await createImageBitmap(canvas);
} finally {
URL.revokeObjectURL(url);
}
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('image decode failed'));
img.src = url;
});
}
+52 -19
View File
@@ -15,7 +15,7 @@ import { get, writable, type Readable } from 'svelte/store';
import { calibrationScale, fillFromSetting, FILL_DEFAULT } from '../png/crop';
import { measure } from '../measure/measure';
import { initialTapeLine, rectAlongLine, slabBounds, minAreaRect } from '../geometry/rect';
import { keepForSheet } from '../mask/sheetKeep';
import { removeBackground, sampleSheetTone } from '../mask/bgRemoval';
import { composeInventoryFrame } from '../png/inventory';
import { eighthsToIn, type Axis, type BatchPhotoResult, type Capture, type Mask, type MaskParams, type Measures, type Sheet, type Source } from './capture.types';
import { buildPhotosReadyLineup, type PhotosReadyLineupInput, type PhotosReadyLineupItem } from './photosReadyLineup';
@@ -99,32 +99,65 @@ export const buildMask = (
};
};
/** Default mask pass: keep predicates from `mask/sheetKeep` over the decoded grid. */
/** Default mask pass: client-side sheet removal (border flood-fill). */
export const defaultMaskRun: MaskRun = async (source, params, decoded) =>
buildMask(params, decoded, walkMask(decoded, params));
/** Walk the decoded RGBA grid through the sheet-keep predicate (CAPTURE-30..60). */
/**
* Mask pass (CAPTURE-30..60): remove the sheet-connected background by
* flood-filling from the image border against the sampled sheet tone, keeping
* the connected remainder as the slab. This is robust to sheet/slab luminance
* overlap that defeats a global brightness threshold.
*
* `sheet`: 'auto' (sampled tone) uses pure connectivity; 'green'/'black' bias
* the sampled tone toward that sheet so the fill anchors correctly.
* `sensitivity` (0–100) maps onto the color distance tolerance.
*/
function walkMask(decoded: DecodedPhoto, params: MaskParams): Uint8Array {
const keep = keepForSheet(params.sheet, params.sensitivity, samplePixels(decoded));
const { width, height, rgba } = decoded;
const bitset = new Uint8Array(width * height);
for (let i = 0; i < width * height; i++) {
bitset[i] = keep(rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]) ? 1 : 0;
const tone = sheetToneFor(params.sheet, decoded);
const tolerance = 20 + (params.sensitivity / 100) * 80; // 20..100 distance
// Downscale to a working resolution so the flood-fill stays fast on large
// photos; the bitset is remapped back to full size for downstream geometry.
const MAX = 1200;
const scale = Math.min(1, MAX / Math.max(width, height));
if (scale >= 0.999) {
return removeBackground(width, height, rgba, { tolerance, sheetTone: tone });
}
return bitset;
}
/** Stride-16 edge-pixel sample for `autoPickSheet` (matches the P1 mask worker). */
function samplePixels(decoded: DecodedPhoto): { r: number; g: number; b: number }[] {
const { width, height, rgba } = decoded;
const samples: { r: number; g: number; b: number }[] = [];
for (let y = 0; y < height; y += 16) {
for (let x = 0; x < width; x += 16) {
const i = (y * width + x) * 4;
samples.push({ r: rgba[i], g: rgba[i + 1], b: rgba[i + 2] });
const sw = Math.round(width * scale);
const sh = Math.round(height * scale);
const small = new Uint8ClampedArray(sw * sh * 4);
for (let y = 0; y < sh; y++) {
for (let x = 0; x < sw; x++) {
const sx = Math.min(width - 1, Math.round((x / sw) * width));
const sy = Math.min(height - 1, Math.round((y / sh) * height));
const si = (sy * width + sx) * 4;
const di = (y * sw + x) * 4;
small[di] = rgba[si];
small[di + 1] = rgba[si + 1];
small[di + 2] = rgba[si + 2];
small[di + 3] = 255;
}
}
return samples;
const smallBitset = removeBackground(sw, sh, small, { tolerance, sheetTone: tone });
// Remap the small bitset back to full resolution (nearest-neighbor upsample).
const full = new Uint8Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const tx = Math.min(sw - 1, Math.round((x / width) * sw));
const ty = Math.min(sh - 1, Math.round((y / height) * sh));
full[y * width + x] = smallBitset[ty * sw + tx];
}
}
return full;
}
/** Sheet tone to anchor the flood fill: sampled border, biased by the choice. */
function sheetToneFor(sheet: MaskParams['sheet'], decoded: DecodedPhoto): { r: number; g: number; b: number } {
const sampled = sampleSheetTone(decoded.width, decoded.height, decoded.rgba);
if (sheet === 'green') return { r: 40, g: 170, b: 60 };
if (sheet === 'black') return { r: 30, g: 30, b: 30 };
return sampled;
}
/** Default inventory encode: P1 frame composer + canvas → image/png blob. */
+22 -1
View File
@@ -1,5 +1,26 @@
export type SlabStatus = 'draft' | 'calibrated' | 'ready' | 'publishing' | 'published' | 'failed';
/**
* Random UUID. crypto.randomUUID is only defined in secure contexts (https/
* localhost); the LAN dev URL (http://192.168.1.202) is not, so fall back to
* crypto.getRandomValues when it is absent.
*/
export function newId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
const bytes = new Uint8Array(16);
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
}
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
export interface Photo {
id: string;
seq: number;
@@ -86,7 +107,7 @@ export interface Taxonomy {
/** Public factory: a fresh SlabDraft with demo defaults; spread to override. */
export function draft(overrides: Partial<SlabDraft> = {}): SlabDraft {
return {
id: crypto.randomUUID(),
id: newId(),
status: 'draft',
photos: [],
calibrationPhotoId: '',
+112
View File
@@ -0,0 +1,112 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { removeBackground, sampleSheetTone, keepLargestComponent } from './bgRemoval';
function grid(w: number, h: number, fill: (x: number, y: number) => [number, number, number]): {
rgba: Uint8ClampedArray;
width: number;
height: number;
} {
const rgba = new Uint8ClampedArray(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const [r, g, b] = fill(x, y);
const i = (y * w + x) * 4;
rgba[i] = r;
rgba[i + 1] = g;
rgba[i + 2] = b;
rgba[i + 3] = 255;
}
}
return { rgba, width: w, height: h };
}
test('removeBackground: slab on a border sheet is kept; sheet removed', () => {
// 10x10; border = dark sheet (30,30,30); inner 4..5 = bright slab (180,140,90)
const { rgba, width, height } = grid(10, 10, (x, y) =>
x >= 3 && x <= 6 && y >= 3 && y <= 6 ? [180, 140, 90] : [30, 30, 30]);
const bitset = removeBackground(width, height, rgba, { tolerance: 40 });
const slabPx = bitset.reduce((s, b) => s + b, 0);
// Inner 4x4 slab kept
assert.equal(slabPx, 16);
// Border pixel is background
assert.equal(bitset[0], 0);
// Center slab pixel kept
assert.equal(bitset[(5 * 10 + 5)], 1);
});
test('removeBackground: slab overlapping sheet luminance is kept via connectivity', () => {
// The failure mode of a global threshold: a dark slab pixel the SAME color as
// the sheet survives ONLY because it is enclosed by bright slab, never
// connected to the border. (A dark pixel touching the border is sheet.)
const { rgba, width, height } = grid(9, 9, (x, y) => {
// slab occupies x,y in 1..7 (a 7x7 region), sheet is the outer ring
if (x >= 1 && x <= 7 && y >= 1 && y <= 7) {
// an inner dark column at x=4, only in interior rows y=2..6 so it is never
// adjacent to the border sheet
if (x === 4 && y >= 2 && y <= 6) return [30, 30, 30];
return [150, 120, 80];
}
return [30, 30, 30]; // sheet (border ring)
});
const bitset = removeBackground(width, height, rgba, { tolerance: 25 });
// The enclosed dark slab column (x=4, y=2..6) must be kept — surrounded by
// bright slab, never connected to the border.
for (let y = 2; y <= 6; y++) {
assert.equal(bitset[y * 9 + 4], 1, `enclosed dark slab px at (4,${y}) kept`);
}
// Border sheet removed
assert.equal(bitset[0], 0);
assert.equal(bitset[8], 0);
assert.equal(bitset[80], 0);
});
test('keepLargestComponent: drops a small isolated blob, keeps the main slab', () => {
// 20x20. Main slab = a 8x8 block at center-left. A small 2x2 "room corner"
// blob sits far away, isolated (not connected to the slab).
const w = 20, h = 20;
const slab = new Uint8Array(w * h);
for (let y = 6; y <= 13; y++) for (let x = 4; x <= 11; x++) slab[y * w + x] = 1; // 8x8 = 64
slab[1 * w + 17] = 1; slab[1 * w + 18] = 1; slab[2 * w + 17] = 1; slab[2 * w + 18] = 1; // 2x2 blob
const out = keepLargestComponent(w, h, slab);
// Main slab fully kept
assert.equal(out[8 * w + 6], 1);
assert.equal(out[12 * w + 10], 1);
// Small isolated blob dropped
assert.equal(out[1 * w + 17], 0, 'isolated blob px dropped');
assert.equal(out[2 * w + 18], 0, 'isolated blob px dropped');
// slabPx = 64 (only the main component)
const kept = out.reduce((s, b) => s + b, 0);
assert.equal(kept, 64);
});
test('removeBackground: a room-corner artifact past the sheet is dropped', () => {
// Border = sheet. Center = bright slab. A bright 3x3 blob in the top-right
// corner IS connected to the border via sheet? No - it's bright, so the
// flood fill (sheet-like only) won't reach it; it becomes a separate slab
// component and must be dropped as the non-largest island.
const w = 16, h = 16;
const rgba = new Uint8ClampedArray(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
let c: [number, number, number] = [30, 30, 30]; // sheet
if (x >= 4 && x <= 10 && y >= 5 && y <= 10) c = [180, 140, 90]; // slab
if (x >= 12 && x <= 14 && y >= 1 && y <= 3) c = [200, 150, 100]; // corner artifact
const i = (y * w + x) * 4;
rgba[i] = c[0]; rgba[i + 1] = c[1]; rgba[i + 2] = c[2]; rgba[i + 3] = 255;
}
}
const bitset = removeBackground(w, h, rgba, { tolerance: 30 });
// Slab kept
assert.equal(bitset[7 * w + 6], 1);
// Corner artifact dropped (smaller component)
assert.equal(bitset[2 * w + 13], 0, 'corner artifact dropped');
});
test('sampleSheetTone: returns the dominant border color', () => {
const { rgba, width, height } = grid(8, 8, () => [40, 170, 60]);
const tone = sampleSheetTone(width, height, rgba);
assert.equal(tone.r, 40);
assert.equal(tone.g, 170);
assert.equal(tone.b, 60);
});
+184
View File
@@ -0,0 +1,184 @@
/**
* Client-side background (sheet) removal for the M1 capture mask (CAPTURE-30..60).
*
* The sheet and the slab frequently OVERLAP in luminance (a "black" sheet often
* reads at lum ~50 while the slab's bark/shadow edges dip into the same band),
* so a global brightness threshold cannot separate them. Instead this uses
* connectivity: the sheet is the region CONNECTED TO THE IMAGE BORDER whose
* pixels are close in color to the sampled sheet tone. Everything not reached
* by that border flood-fill is the slab.
*
* This is the standard, robust approach used by the prior prototypes and is
* stable across lighting because it anchors on the actual sheet color present
* in the frame, not an assumed black/green.
*/
export interface BgRemovalOptions {
/**
* Tolerance for "sheet-like": a pixel joins the background flood-fill if its
* per-channel distance to the sampled sheet tone is within this value.
* Maps the UI "sensitivity" (0–100) onto a 0–~120 distance. Higher = more
* aggressive removal (wider tolerance).
*/
tolerance: number;
/** Optional fixed sheet tone; sampled from the border when omitted. */
sheetTone?: { r: number; g: number; b: number };
}
/** Sample the sheet tone from the outer border ring (median of border pixels). */
export function sampleSheetTone(
width: number,
height: number,
rgba: Uint8Array | Uint8ClampedArray,
): { r: number; g: number; b: number } {
const rs: number[] = [];
const gs: number[] = [];
const bs: number[] = [];
const step = Math.max(1, Math.floor(Math.max(width, height) / 200));
const push = (x: number, y: number) => {
const i = (y * width + x) * 4;
rs.push(rgba[i]);
gs.push(rgba[i + 1]);
bs.push(rgba[i + 2]);
};
for (let x = 0; x < width; x += step) {
push(x, 0);
push(x, height - 1);
}
for (let y = 0; y < height; y += step) {
push(0, y);
push(width - 1, y);
}
const median = (arr: number[]) => {
const s = [...arr].sort((a, b) => a - b);
return s[Math.floor(s.length / 2)] ?? 0;
};
return { r: median(rs), g: median(gs), b: median(bs) };
}
/**
* Remove the sheet-connected background. Returns a slab bitset (1 = keep/slab).
* Flood-fills from every border pixel; a neighbor joins the fill only while it
* stays within `tolerance` of the sheet tone. The slab is the complement.
*/
export function removeBackground(
width: number,
height: number,
rgba: Uint8Array | Uint8ClampedArray,
opts: BgRemovalOptions,
): Uint8Array {
const tone = opts.sheetTone ?? sampleSheetTone(width, height, rgba);
const tol2 = opts.tolerance * opts.tolerance;
const isSheetLike = (i: number) => {
const dr = rgba[i] - tone.r;
const dg = rgba[i + 1] - tone.g;
const db = rgba[i + 2] - tone.b;
return dr * dr + dg * dg + db * db <= tol2;
};
const n = width * height;
const visited = new Uint8Array(n); // 1 = reached by flood fill (= background)
const stack: number[] = [];
// Seed from all border pixels that look sheet-like.
const seed = (x: number, y: number) => {
const idx = y * width + x;
if (visited[idx]) return;
if (isSheetLike(idx * 4)) {
visited[idx] = 1;
stack.push(idx);
}
};
for (let x = 0; x < width; x++) {
seed(x, 0);
seed(x, height - 1);
}
for (let y = 0; y < height; y++) {
seed(0, y);
seed(width - 1, y);
}
// Iterative flood fill (4-neighbor).
while (stack.length > 0) {
const idx = stack.pop()!;
const x = idx % width;
const y = (idx / width) | 0;
// 4-neighbors
if (x > 0 && !visited[idx - 1] && isSheetLike((idx - 1) * 4)) {
visited[idx - 1] = 1;
stack.push(idx - 1);
}
if (x < width - 1 && !visited[idx + 1] && isSheetLike((idx + 1) * 4)) {
visited[idx + 1] = 1;
stack.push(idx + 1);
}
if (y > 0 && !visited[idx - width] && isSheetLike((idx - width) * 4)) {
visited[idx - width] = 1;
stack.push(idx - width);
}
if (y < height - 1 && !visited[idx + width] && isSheetLike((idx + width) * 4)) {
visited[idx + width] = 1;
stack.push(idx + width);
}
}
const slab = new Uint8Array(n);
for (let i = 0; i < n; i++) slab[i] = visited[i] ? 0 : 1;
return keepLargestComponent(width, height, slab);
}
/**
* Keep only the largest 4-connected slab component. Small isolated blobs
* (e.g. a room corner the photo caught past the sheet) are not connected to
* the main slab and are dropped. A minimum-area floor (default: 0.5% of the
* frame) also drops speckles even if they are the "largest" of the remainder.
*/
export function keepLargestComponent(
width: number,
height: number,
slab: Uint8Array,
minAreaFrac = 0.005,
): Uint8Array {
const n = width * height;
const label = new Int32Array(n).fill(-1);
const sizes: number[] = [];
const stack: number[] = [];
let best = -1;
let bestSize = 0;
const minArea = Math.floor(n * minAreaFrac);
for (let start = 0; start < n; start++) {
if (slab[start] !== 1 || label[start] !== -1) continue;
const id = sizes.length;
label[start] = id;
stack.length = 0;
stack.push(start);
let size = 0;
while (stack.length > 0) {
const idx = stack.pop()!;
size++;
const x = idx % width;
const y = (idx / width) | 0;
const tryPush = (ni: number) => {
if (slab[ni] === 1 && label[ni] === -1) {
label[ni] = id;
stack.push(ni);
}
};
if (x > 0) tryPush(idx - 1);
if (x < width - 1) tryPush(idx + 1);
if (y > 0) tryPush(idx - width);
if (y < height - 1) tryPush(idx + width);
}
sizes.push(size);
if (size > bestSize) {
bestSize = size;
best = id;
}
}
if (best === -1 || bestSize < minArea) return slab; // nothing, or too small to trust
const out = new Uint8Array(n);
for (let i = 0; i < n; i++) out[i] = label[i] === best ? 1 : 0;
return out;
}
+55 -13
View File
@@ -3,7 +3,7 @@
import { apiFetch, ApiError } from '../lib/api';
import type { SlabDraft, Taxonomy, TaxonomyCategory, TaxonomyTerm } from '../lib/types';
import { deriveThicknessBandId } from './thicknessBand';
import { keptInOrder, missingMinimums, moveItem, roleLabel, resolvePhotoUrl, type LineupItem, type PhotoRole } from './lineup';
import { keptInOrder, missingMinimums, moveItem, photoRole, roleLabel, resolvePhotoUrl, type LineupItem, type PhotoRole } from './lineup';
import { nextPrimary, selectedSummary } from './woodPrimary';
import { buildShortDescription, buildTitle } from './copy';
@@ -34,17 +34,20 @@
$: categories = taxonomy?.categories ?? [];
$: speciesLeaves = leafChildren(categories, 'species', 'Species');
$: woodCatLeaves = leafChildren(categories, 'wcat', 'Wood category');
$: attributeByName = (name: string) =>
(taxonomy?.attributes ?? []).find((a) => a.name.toLowerCase() === name || a.slug.toLowerCase() === name);
$: attributeByName = (name: string) => {
const n = name.toLowerCase();
return (taxonomy?.attributes ?? []).find((a) => a.name.toLowerCase() === n || a.slug.toLowerCase() === n);
};
$: edgeTerms = attributeByName('Edge Type')?.terms ?? [];
$: figureTerms = attributeByName('Figure')?.terms ?? [];
$: gradeTerms = attributeByName('Grade')?.terms ?? [];
$: thicknessTerms = attributeByName('Thickness')?.terms ?? [];
$: moistureTerms = attributeByName('Moisture')?.terms ?? [];
$: figTags = (taxonomy?.tags ?? []).filter((t) => t.name.startsWith('fig-'));
$: featTags = (taxonomy?.tags ?? []).filter((t) => t.name.startsWith('feat-'));
// The fig-/feat- family marker is on the tag SLUG (slug 'fig-chat', name
// 'Chatoyant'), so match slug OR name (METADATA-30: bare names are kept).
$: figTags = (taxonomy?.tags ?? []).filter((t) => t.slug.startsWith('fig-') || t.name.startsWith('fig-'));
$: featTags = (taxonomy?.tags ?? []).filter((t) => t.slug.startsWith('feat-') || t.name.startsWith('feat-'));
$: speciesName = (id: number | null) => categories.find((c) => c.id === id)?.name ?? '';
$: figureName = figureTerms.find((t) => t.id === (slab.figureTermIds?.[0] ?? null))?.name ?? null;
// ── Draft fields ──
let sku = slab.sku || '';
@@ -70,17 +73,25 @@
$: edgeName = edgeTerms.find((t) => t.id === edgeId)?.name ?? null;
$: primaryLeaf = categories.find((c) => c.id === primaryCatId);
// Read the figure from the live selection, not the `figureName` reactive:
// this runs synchronously from a click handler, before Svelte flushes the
// reactive update, so the reactive would still hold the previous value.
function currentFigureName(): string | null {
return figureTerms.find((t) => t.id === (figIds[0] ?? null))?.name ?? null;
}
function regenerateCopy() {
if (!primaryLeaf || !speciesName(speciesId as number)) return;
const figure = currentFigureName();
title = buildTitle(
{ lengthIn: slab.lengthIn, widthAvgIn: slab.widthAvgIn, thicknessIn: slab.thicknessIn, bdft: slab.bdft, edgeName },
figureName,
figure,
speciesName(speciesId as number),
{ slug: primaryLeaf.slug, name: primaryLeaf.name },
);
shortDesc = buildShortDescription(
{ lengthIn: slab.lengthIn, widthAvgIn: slab.widthAvgIn, thicknessIn: slab.thicknessIn, bdft: slab.bdft, edgeName },
figureName,
figure,
speciesName(speciesId as number),
{ slug: primaryLeaf.slug, name: primaryLeaf.name },
);
@@ -106,7 +117,7 @@
// ── Photo strip (REVIEW-5): kept order, leftmost = primary (PUBLISH-45) ──
$: lineup = slab.photos.map((photo, index): LineupItem => ({
id: photo.id,
role: photo.id === slab.calibrationPhotoId ? 'calibration' : photo.processedBlob ? 'overlay' : 'batch',
role: photoRole(photo.id, slab.calibrationPhotoId, Boolean(photo.processedBlob)),
isCalibration: photo.id === slab.calibrationPhotoId,
kept: index < slab.photos.length,
}));
@@ -148,12 +159,43 @@
primaryCatId = nextPrimary(selected, primaryCatId);
regenerateCopy();
}
// Figure selections echo transparently between the Figure attribute terms
// (pa_fig) and the synced fig-* product tags, so the Woo product carries the
// same figure in both places without the owner picking it twice. Matched
// case-insensitively on the display name (attribute "Curly" -> tag "Curly").
const normName = (s: string) => s.trim().toLowerCase();
function echoFigTag(termId: number, selected: boolean) {
const term = figureTerms.find((t) => t.id === termId);
if (!term) return;
const key = normName(term.name);
const match = figTags.find((t) => normName(t.name) === key);
if (match) figTagIds = toggleTag(figTagIds, match.id, selected);
}
function echoFigTerm(tagId: number, selected: boolean) {
const tag = figTags.find((t) => t.id === tagId);
if (!tag) return;
const key = normName(tag.name);
const match = figureTerms.find((t) => normName(t.name) === key);
if (match) figIds = selected ? [...new Set([...figIds, match.id])] : figIds.filter((x) => x !== match.id);
}
function toggleFig(id: number) {
figIds = figIds.includes(id) ? figIds.filter((x) => x !== id) : [...figIds, id];
const next = figIds.includes(id) ? figIds.filter((x) => x !== id) : [...figIds, id];
figIds = next;
echoFigTag(id, next.includes(id));
regenerateCopy();
}
function toggleTag(list: number[], id: number): number[] {
return list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
function toggleFigTag(id: number) {
const next = toggleTag(figTagIds, id);
figTagIds = next;
echoFigTerm(id, next.includes(id));
regenerateCopy();
}
function toggleTag(list: number[], id: number, forceSelected?: boolean): number[] {
const on = forceSelected ?? !list.includes(id);
return on ? [...new Set([...list, id])] : list.filter((x) => x !== id);
}
// ── Call 2: long description (METADATA-115) with template fallback ──
@@ -291,7 +333,7 @@
<div class="chip-group">
<span>Figure tags</span>
{#each figTags as t (t.id)}
<button class="chip" class:selected={figTagIds.includes(t.id)} on:click={() => (figTagIds = toggleTag(figTagIds, t.id))}>{t.name}</button>
<button class="chip" class:selected={figTagIds.includes(t.id)} on:click={() => toggleFigTag(t.id)}>{t.name}</button>
{/each}
</div>
{/if}
+12
View File
@@ -5,6 +5,7 @@ import {
moveItem,
missingMinimums,
lineupValid,
photoRole,
roleLabel,
resolvePhotoUrl,
type LineupItem,
@@ -63,6 +64,17 @@ test('lineup: missingMinimums only counts KEPT photos', () => {
assert.deepEqual(missingMinimums([p('cal', 'calibration', false), p('ov', 'overlay')]), ['calibration']);
});
test('lineup: photoRole is the single role rule (cal > overlay > batch)', () => {
assert.equal(photoRole('cal', 'cal', true), 'calibration');
assert.equal(photoRole('ov', 'cal', true), 'overlay');
assert.equal(photoRole('b1', 'cal', false), 'batch');
// Calibration wins even if it carries a processed copy.
assert.equal(photoRole('cal', 'cal', true), 'calibration');
// No calibration selected: processed photos are overlays.
assert.equal(photoRole('ov', '', true), 'overlay');
assert.equal(photoRole('b1', '', false), 'batch');
});
test('lineup: roleLabel is owner-facing', () => {
assert.match(roleLabel('calibration'), /clean/);
assert.match(roleLabel('overlay'), /overlay/);
+13
View File
@@ -18,6 +18,19 @@ export interface LineupItem {
kept: boolean;
}
/**
* The submission role of a photo: the calibration photo is `calibration`; a
* photo carrying its dimension-overlay copy (`processedBlob`) is `overlay`;
* everything else is `batch`. Single definition of the role rule — the review
* strip (ReviewForm) and the server photo upload (photoUpload) both derive from
* this so they can never disagree.
*/
export function photoRole(id: string, calibrationPhotoId: string, hasProcessed: boolean): PhotoRole {
if (id === calibrationPhotoId) return 'calibration';
if (hasProcessed) return 'overlay';
return 'batch';
}
/** The kept photos in their current (left→right) order; the first is the primary. */
export function keptInOrder(items: LineupItem[]): LineupItem[] {
return items.filter((it) => it.kept);
+48
View File
@@ -0,0 +1,48 @@
// Multipart photo upload to the server (POST /api/v1/slabs/{id}/photos,
// backend/photos.py). Kept out of App.svelte so the create→publish path stays
// readable. Reuses the single role definition from lineup.ts (photoRole) and
// the apiFetch conventions (same /api/v1 base, same ApiError envelope) so
// failures surface through the existing fail(e) path.
import { ApiError, type ApiErrorBody } from '../lib/api';
import type { Photo } from '../lib/types';
import { photoRole } from './lineup';
export interface UploadedPhoto {
id: string;
seq: number;
is_calibration: number;
role: string;
}
function blobFile(blob: Blob, name: string, type?: string): File {
return blob instanceof File ? blob : new File([blob], name, { type });
}
/**
* Upload every draft photo (original + processed where present) to the server,
* in seq order. Throws an ApiError on the first photo that fails — the slab
* row must exist first (its id is the path segment), so the caller runs this
* after POST /slabs.
*/
export async function uploadSlabPhotos(slabId: string, photos: Photo[], calibrationPhotoId: string): Promise<UploadedPhoto[]> {
const uploaded: UploadedPhoto[] = [];
for (const photo of photos) {
const form = new FormData();
form.append('photo_id', photo.id);
form.append('seq', String(photo.seq));
form.append('is_calibration', photo.id === calibrationPhotoId ? '1' : '0');
form.append('role', photoRole(photo.id, calibrationPhotoId, Boolean(photo.processedBlob)));
form.append('original', blobFile(photo.blob, `${photo.id}.png`, photo.blob.type || 'image/png'));
if (photo.processedBlob) {
form.append('processed', blobFile(photo.processedBlob, `${photo.id}-processed.png`, photo.processedBlob.type || 'image/png'));
}
const res = await fetch(`/api/v1/slabs/${slabId}/photos`, { method: 'POST', body: form });
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: ApiErrorBody } | null;
throw new ApiError(res.status, body?.error ?? { code: String(res.status), message: `HTTP ${res.status}` });
}
uploaded.push((await res.json()) as UploadedPhoto);
}
return uploaded;
}
+7
View File
@@ -5,4 +5,11 @@ import sveltePreprocess from 'svelte-preprocess';
export default defineConfig({
plugins: [svelte({ preprocess: sveltePreprocess() })],
worker: { format: 'es' },
server: {
port: 5173,
proxy: {
// Local dev: route API calls to the FastAPI backend (mock-safe).
'/api': { target: 'http://127.0.0.1:18081', changeOrigin: true }
}
}
});