Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4832dbf09 | |||
| 83b58c23a3 | |||
| 350a849ab6 | |||
| 104f29f5e3 | |||
| dfe192fa8a | |||
| 23de433bdb | |||
| e5afc7524b | |||
| 5c7e5c663e | |||
| fa54e113c3 | |||
| bffbad8c4f | |||
| beab1b5bb7 | |||
| 1b32f71c91 | |||
| 039a5de960 | |||
| 564f03dcee | |||
| 0bc86ecc6d | |||
| 703655a763 | |||
| e4a05dd468 | |||
| 25c1f79fc1 | |||
| 1c58f12c5d | |||
| 673593c9b6 | |||
| b03939cf84 | |||
| 3321a7cfdf | |||
| 7913e630d6 | |||
| de2af5c6c7 | |||
| 88a2518cfa | |||
| e09901b701 | |||
| 86091ed916 |
+4
-2
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user