Add physical dimensions to bin editor for correct aspect ratio
When de-perspectiving a bin photo taken at an angle, inferring the output size from the quadrilateral shape squishes the result. Entering real-world W×H (mm) lets the server use the correct aspect ratio, scaled to the same resolution as the inferred size. - Bin editor dialog: W×H number inputs, pre-filled from saved phys_w/phys_h - PUT /api/bins/:id/corners: accepts optional phys_w/phys_h; when provided, derives bin_w/bin_h from the physical aspect ratio at equivalent area - phys_w/phys_h stored on the bin record for re-use on next edit - future-plans.md: bin types note (reusable dimensions per model) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -381,6 +381,16 @@ used/visited locations at the top so you can quickly re-select where you just we
|
|||||||
Useful when processing a batch of components into the same storage location — you
|
Useful when processing a batch of components into the same storage location — you
|
||||||
shouldn't have to navigate the grid picker from scratch each time.
|
shouldn't have to navigate the grid picker from scratch each time.
|
||||||
|
|
||||||
|
## Bins
|
||||||
|
|
||||||
|
### Bin types
|
||||||
|
Define reusable bin type records (e.g. "Sortimo L-Boxx insert small", "Wago 221
|
||||||
|
connector box") that store physical dimensions (mm), and optionally a default
|
||||||
|
compartment layout. When creating or editing a bin, the user picks a type and the
|
||||||
|
dimensions are pre-filled — no need to re-enter for every bin of the same model.
|
||||||
|
This also enables filtering/grouping bins by type, and makes it easy to re-process
|
||||||
|
all bins of a type if the corner algorithm improves.
|
||||||
|
|
||||||
## Grids
|
## Grids
|
||||||
|
|
||||||
### Grid view layers
|
### Grid view layers
|
||||||
|
|||||||
@@ -2106,6 +2106,8 @@ function open_bin_editor(bin) {
|
|||||||
|
|
||||||
bin_editor_bin_id = bin.id;
|
bin_editor_bin_id = bin.id;
|
||||||
document.getElementById('bin-editor-name').value = bin.name;
|
document.getElementById('bin-editor-name').value = bin.name;
|
||||||
|
document.getElementById('bin-editor-width').value = bin.phys_w ?? '';
|
||||||
|
document.getElementById('bin-editor-height').value = bin.phys_h ?? '';
|
||||||
|
|
||||||
// Show dialog first so the canvas has correct layout dimensions before
|
// Show dialog first so the canvas has correct layout dimensions before
|
||||||
// load_image reads parentElement.clientWidth to size itself.
|
// load_image reads parentElement.clientWidth to size itself.
|
||||||
@@ -2353,6 +2355,8 @@ async function init() {
|
|||||||
document.getElementById('bin-editor-save').addEventListener('click', async () => {
|
document.getElementById('bin-editor-save').addEventListener('click', async () => {
|
||||||
const corners = bin_editor_instance?.get_corners();
|
const corners = bin_editor_instance?.get_corners();
|
||||||
const name = document.getElementById('bin-editor-name').value.trim();
|
const name = document.getElementById('bin-editor-name').value.trim();
|
||||||
|
const phys_w = parseFloat(document.getElementById('bin-editor-width').value) || null;
|
||||||
|
const phys_h = parseFloat(document.getElementById('bin-editor-height').value) || null;
|
||||||
if (!corners) { alert('Load an image first.'); return; }
|
if (!corners) { alert('Load an image first.'); return; }
|
||||||
const id = bin_editor_bin_id;
|
const id = bin_editor_bin_id;
|
||||||
try {
|
try {
|
||||||
@@ -2361,7 +2365,7 @@ async function init() {
|
|||||||
const r = await api.rename_bin(id, name);
|
const r = await api.rename_bin(id, name);
|
||||||
updated = r.bin;
|
updated = r.bin;
|
||||||
}
|
}
|
||||||
const r2 = await api.update_bin_corners(id, corners);
|
const r2 = await api.update_bin_corners(id, corners, phys_w, phys_h);
|
||||||
updated = r2.bin;
|
updated = r2.bin;
|
||||||
all_bins = all_bins.map(b => b.id === id ? updated : b);
|
all_bins = all_bins.map(b => b.id === id ? updated : b);
|
||||||
document.getElementById('dialog-bin-editor').close();
|
document.getElementById('dialog-bin-editor').close();
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const get_bins = () => req('GET', '/api/bins');
|
|||||||
export const create_bin_from_source = (source_id, name) => req('POST', '/api/bins/from-source', { source_id, name });
|
export const create_bin_from_source = (source_id, name) => req('POST', '/api/bins/from-source', { source_id, name });
|
||||||
export const get_bin = (id) => req('GET', `/api/bins/${id}`);
|
export const get_bin = (id) => req('GET', `/api/bins/${id}`);
|
||||||
export const rename_bin = (id, name) => req('PUT', `/api/bins/${id}`, { name });
|
export const rename_bin = (id, name) => req('PUT', `/api/bins/${id}`, { name });
|
||||||
export const update_bin_corners = (id, corners) => req('PUT', `/api/bins/${id}/corners`, { corners });
|
export const update_bin_corners = (id, corners, phys_w, phys_h) => req('PUT', `/api/bins/${id}/corners`, { corners, phys_w, phys_h });
|
||||||
export const delete_bin = (id) => req('DELETE', `/api/bins/${id}`);
|
export const delete_bin = (id) => req('DELETE', `/api/bins/${id}`);
|
||||||
|
|
||||||
export async function upload_bin(file, name) {
|
export async function upload_bin(file, name) {
|
||||||
|
|||||||
@@ -1972,6 +1972,22 @@ nav {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bin-editor-dims {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-editor-dims input {
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.bin-editor-canvas {
|
.bin-editor-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|||||||
@@ -656,6 +656,15 @@
|
|||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input type="text" id="bin-editor-name" autocomplete="off">
|
<input type="text" id="bin-editor-name" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Dimensions (mm)</label>
|
||||||
|
<div class="bin-editor-dims">
|
||||||
|
<input type="number" id="bin-editor-width" placeholder="W" min="1" step="1">
|
||||||
|
<span>×</span>
|
||||||
|
<input type="number" id="bin-editor-height" placeholder="H" min="1" step="1">
|
||||||
|
<span class="form-hint">Leave blank to infer from corners</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<canvas id="bin-editor-canvas" class="bin-editor-canvas"></canvas>
|
<canvas id="bin-editor-canvas" class="bin-editor-canvas"></canvas>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<button type="button" class="btn btn-secondary" id="bin-editor-cancel">Cancel</button>
|
<button type="button" class="btn btn-secondary" id="bin-editor-cancel">Cancel</button>
|
||||||
|
|||||||
23
server.mjs
23
server.mjs
@@ -741,19 +741,34 @@ app.post('/api/bins', upload.single('image'), async (req, res) => {
|
|||||||
app.put('/api/bins/:id/corners', async (req, res) => {
|
app.put('/api/bins/:id/corners', async (req, res) => {
|
||||||
const bin = get_bin(req.params.id);
|
const bin = get_bin(req.params.id);
|
||||||
if (!bin) return fail(res, 'not found', 404);
|
if (!bin) return fail(res, 'not found', 404);
|
||||||
const { corners } = req.body;
|
const { corners, phys_w, phys_h } = req.body;
|
||||||
if (!corners || corners.length !== 4) return fail(res, 'corners must be array of 4 points');
|
if (!corners || corners.length !== 4) return fail(res, 'corners must be array of 4 points');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Delete old processed image if any
|
|
||||||
if (bin.image_filename) remove_image_file(bin.image_filename);
|
if (bin.image_filename) remove_image_file(bin.image_filename);
|
||||||
|
|
||||||
const source_path = join('./data/images', bin.source_id);
|
const source_path = join('./data/images', bin.source_id);
|
||||||
const { bin_w, bin_h } = compute_bin_size(corners);
|
let bin_w, bin_h;
|
||||||
|
if (phys_w > 0 && phys_h > 0) {
|
||||||
|
// Use physical aspect ratio scaled to the same area as computed size
|
||||||
|
const computed = compute_bin_size(corners);
|
||||||
|
const area = computed.bin_w * computed.bin_h;
|
||||||
|
const aspect = phys_w / phys_h;
|
||||||
|
bin_h = Math.round(Math.sqrt(area / aspect));
|
||||||
|
bin_w = Math.round(bin_h * aspect);
|
||||||
|
} else {
|
||||||
|
({ bin_w, bin_h } = compute_bin_size(corners));
|
||||||
|
}
|
||||||
|
|
||||||
const cells = await process_grid_image(source_path, corners, 1, 1, bin_w, bin_h, './data/images');
|
const cells = await process_grid_image(source_path, corners, 1, 1, bin_w, bin_h, './data/images');
|
||||||
const image_filename = cells[0][0];
|
const image_filename = cells[0][0];
|
||||||
|
|
||||||
const updated = { ...bin, corners, image_filename, bin_w, bin_h, updated_at: Date.now() };
|
const updated = {
|
||||||
|
...bin, corners, image_filename, bin_w, bin_h,
|
||||||
|
phys_w: phys_w > 0 ? phys_w : (bin.phys_w ?? null),
|
||||||
|
phys_h: phys_h > 0 ? phys_h : (bin.phys_h ?? null),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
};
|
||||||
set_bin(updated);
|
set_bin(updated);
|
||||||
ok(res, { bin: updated });
|
ok(res, { bin: updated });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user