648 lines
26 KiB
HTML
648 lines
26 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Annotate photo — {{ session.label }}{% endblock %}
|
|
{% block content %}
|
|
|
|
<div style="margin-bottom:1rem;">
|
|
<div style="font-size:0.82rem;color:#888;margin-bottom:.3rem;">
|
|
<a href="{{ url_for('sessions.index') }}">Sessions</a> ›
|
|
<a href="{{ url_for('sessions.detail', session_id=session.id) }}">{{ session.label }}</a> ›
|
|
Annotate
|
|
</div>
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;">
|
|
<h1 style="margin:0;">{{ photo.caption or 'Photo annotation' }}</h1>
|
|
<a href="{{ url_for('sessions.detail', session_id=session.id) }}"
|
|
style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;
|
|
padding:.45rem 1rem;font-size:0.88rem;text-decoration:none;white-space:nowrap;">
|
|
✕ Close
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:flex;gap:1.5rem;align-items:flex-start;">
|
|
|
|
{# ── Canvas ── #}
|
|
<div style="flex:1;min-width:0;">
|
|
<canvas id="ann-canvas"
|
|
style="width:100%;border-radius:6px;cursor:crosshair;display:block;
|
|
box-shadow:0 2px 8px rgba(0,0,0,.18);background:#111;"></canvas>
|
|
</div>
|
|
|
|
{# ── Control panel ── #}
|
|
<div style="width:260px;flex-shrink:0;">
|
|
|
|
{# Step indicator #}
|
|
<div style="margin-bottom:1.25rem;">
|
|
<div id="si-0" class="si"
|
|
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
|
|
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
|
|
Reference line
|
|
</div>
|
|
<div id="si-1" class="si"
|
|
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
|
|
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
|
|
Point of Aim
|
|
</div>
|
|
<div id="si-2" class="si"
|
|
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
|
|
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
|
|
Points of Impact
|
|
</div>
|
|
<div id="si-3" class="si"
|
|
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;font-size:0.88rem;">
|
|
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
|
|
Results
|
|
</div>
|
|
</div>
|
|
|
|
<hr style="border:none;border-top:1px solid #e8e8e8;margin-bottom:1rem;">
|
|
|
|
{# Shooting distance (always visible, pre-filled from session) #}
|
|
<div style="margin-bottom:.75rem;">
|
|
<label style="display:block;font-size:.82rem;font-weight:600;color:#444;margin-bottom:.3rem;">Shooting distance</label>
|
|
<div style="display:flex;gap:.4rem;">
|
|
<input type="number" id="shoot-dist" min="1" step="1" placeholder="100"
|
|
style="flex:1;padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
|
|
<select id="shoot-unit"
|
|
style="padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
|
|
<option value="m">m</option>
|
|
<option value="yd">yd</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{# Clean barrel checkbox #}
|
|
<div style="margin-bottom:1rem;">
|
|
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:0.88rem;color:#444;">
|
|
<input type="checkbox" id="clean-barrel" style="width:1rem;height:1rem;">
|
|
Clean barrel (first shot)
|
|
</label>
|
|
</div>
|
|
|
|
<hr style="border:none;border-top:1px solid #e8e8e8;margin-bottom:1rem;">
|
|
|
|
{# Step 0: Reference line #}
|
|
<div id="panel-0" class="step-panel">
|
|
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
|
|
Click <strong>two points</strong> on the image to draw a reference line — e.g. a known grid square or target diameter.
|
|
</p>
|
|
<div id="ref-dist-row" style="display:none;margin-bottom:.75rem;">
|
|
<label style="display:block;font-size:.82rem;font-weight:600;color:#444;margin-bottom:.3rem;">Real distance</label>
|
|
<div style="display:flex;gap:.4rem;">
|
|
<input type="number" id="ref-dist" min="0.1" step="0.1" placeholder="50"
|
|
style="flex:1;padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
|
|
<select id="ref-unit"
|
|
style="padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
|
|
<option value="mm">mm</option>
|
|
<option value="cm">cm</option>
|
|
<option value="in">in</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
|
|
<button class="btn-primary" id="btn-next-0" disabled onclick="goStep(1)">Next →</button>
|
|
<button class="btn-ghost" onclick="resetRef()">Reset</button>
|
|
</div>
|
|
</div>
|
|
|
|
{# Step 1: POA #}
|
|
<div id="panel-1" class="step-panel" style="display:none;">
|
|
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
|
|
Click your <strong>Point of Aim</strong> — the center of the target or wherever you were aiming.
|
|
</p>
|
|
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
|
|
<button class="btn-ghost" onclick="goStep(0)">← Back</button>
|
|
<button class="btn-ghost" onclick="poa=null;redraw();">Reset POA</button>
|
|
</div>
|
|
</div>
|
|
|
|
{# Step 2: POIs #}
|
|
<div id="panel-2" class="step-panel" style="display:none;">
|
|
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
|
|
Click each <strong>bullet hole</strong>. Click an existing point to remove it.
|
|
</p>
|
|
<p id="poi-count" style="font-size:0.88rem;font-weight:600;color:#1a1a2e;margin-bottom:.75rem;">0 impacts</p>
|
|
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
|
|
<button class="btn-primary" id="btn-compute" disabled onclick="compute()">Compute →</button>
|
|
<button class="btn-ghost" onclick="goStep(1)">← Back</button>
|
|
<button class="btn-ghost" onclick="undoPoi()">Undo last</button>
|
|
</div>
|
|
</div>
|
|
|
|
{# Step 3: Results #}
|
|
<div id="panel-3" class="step-panel" style="display:none;">
|
|
<div id="results-box" style="margin-bottom:1rem;"></div>
|
|
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
|
|
<button class="btn-primary" id="btn-save" onclick="saveAnnotations()">Save & close</button>
|
|
<button class="btn-ghost" onclick="goStep(2)">← Edit</button>
|
|
</div>
|
|
<div id="save-status" style="font-size:0.82rem;margin-top:.5rem;"></div>
|
|
</div>
|
|
|
|
</div>{# end control panel #}
|
|
</div>
|
|
|
|
<style>
|
|
.btn-primary {
|
|
background: #1a1a2e; color: #fff; border: none; border-radius: 4px;
|
|
padding: .5rem 1rem; font-size: 0.88rem; cursor: pointer; font-family: inherit;
|
|
}
|
|
.btn-primary:disabled { background: #aaa; cursor: not-allowed; }
|
|
.btn-ghost {
|
|
background: #f0f4ff; color: #1a1a2e; border: 1px solid #c8d4f0; border-radius: 4px;
|
|
padding: .5rem 1rem; font-size: 0.88rem; cursor: pointer; font-family: inherit;
|
|
}
|
|
.stat-row { display: flex; justify-content: space-between; font-size: 0.85rem;
|
|
padding: .3rem 0; border-bottom: 1px solid #f0f0f0; }
|
|
.stat-label { color: #666; }
|
|
.stat-val { font-weight: 600; color: #1a1a2e; }
|
|
.stat-section { font-size: 0.78rem; text-transform: uppercase; letter-spacing: .05em;
|
|
color: #888; margin: .75rem 0 .35rem; }
|
|
</style>
|
|
|
|
<script>
|
|
const PHOTO_URL = {{ photo.photo_url | tojson }};
|
|
const SAVE_URL = {{ url_for('sessions.annotate_photo', session_id=session.id, photo_id=photo.id) | tojson }};
|
|
const SESSION_URL = {{ url_for('sessions.detail', session_id=session.id) | tojson }};
|
|
const SESSION_DIST_M = {{ (session.distance_m or 'null') }};
|
|
const EXISTING = {{ (photo.annotations or {}) | tojson }};
|
|
|
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
let step = 0;
|
|
// Reference: coords in natural image pixels (fractions stored on save)
|
|
let refP1 = null, refP2 = null, refMm = null;
|
|
let refClickStage = 0; // 0=waiting p1, 1=waiting p2, 2=done
|
|
let poa = null; // natural px
|
|
let pois = []; // natural px array
|
|
let stats = null;
|
|
let mousePos = null; // canvas px, for rubber-band
|
|
|
|
// ── Canvas / image ─────────────────────────────────────────────────────────
|
|
const canvas = document.getElementById('ann-canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
img.src = PHOTO_URL;
|
|
img.onload = () => { resizeCanvas(); loadExisting(); redraw(); };
|
|
|
|
function resizeCanvas() {
|
|
// Canvas internal size = natural image size (so all coords stay in nat px)
|
|
canvas.width = img.naturalWidth;
|
|
canvas.height = img.naturalHeight;
|
|
}
|
|
|
|
// Convert canvas mouse event → natural image pixels
|
|
function evToNat(e) {
|
|
const r = canvas.getBoundingClientRect();
|
|
const sx = img.naturalWidth / r.width;
|
|
const sy = img.naturalHeight / r.height;
|
|
return { x: (e.clientX - r.left) * sx, y: (e.clientY - r.top) * sy };
|
|
}
|
|
|
|
// ── Mouse events ───────────────────────────────────────────────────────────
|
|
canvas.addEventListener('mousemove', e => {
|
|
if (step === 0 && refClickStage === 1) {
|
|
const r = canvas.getBoundingClientRect();
|
|
const sx = img.naturalWidth / r.width;
|
|
const sy = img.naturalHeight / r.height;
|
|
mousePos = { x: (e.clientX - r.left) * sx, y: (e.clientY - r.top) * sy };
|
|
redraw();
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener('click', e => {
|
|
const p = evToNat(e);
|
|
if (step === 0) handleRefClick(p);
|
|
else if (step === 1) handlePoaClick(p);
|
|
else if (step === 2) handlePoiClick(p);
|
|
});
|
|
|
|
canvas.addEventListener('mouseleave', () => { mousePos = null; redraw(); });
|
|
|
|
// ── Step 0: Reference line ─────────────────────────────────────────────────
|
|
function handleRefClick(p) {
|
|
if (refClickStage === 0) {
|
|
refP1 = p; refP2 = null; refClickStage = 1;
|
|
canvas.style.cursor = 'crosshair';
|
|
redraw();
|
|
} else if (refClickStage === 1) {
|
|
refP2 = p; refClickStage = 2; mousePos = null;
|
|
document.getElementById('ref-dist-row').style.display = '';
|
|
redraw();
|
|
updateNextBtn0();
|
|
}
|
|
}
|
|
|
|
function resetRef() {
|
|
refP1 = refP2 = null; refClickStage = 0; refMm = null; mousePos = null;
|
|
document.getElementById('ref-dist-row').style.display = 'none';
|
|
document.getElementById('ref-dist').value = '';
|
|
updateNextBtn0(); redraw();
|
|
}
|
|
|
|
document.getElementById('ref-dist').addEventListener('input', updateNextBtn0);
|
|
|
|
function updateNextBtn0() {
|
|
const v = parseFloat(document.getElementById('ref-dist').value);
|
|
document.getElementById('btn-next-0').disabled = !(refP1 && refP2 && v > 0);
|
|
}
|
|
|
|
// ── Step 1: POA ────────────────────────────────────────────────────────────
|
|
function handlePoaClick(p) {
|
|
poa = p; redraw();
|
|
// Auto-advance to step 2
|
|
goStep(2);
|
|
}
|
|
|
|
// ── Step 2: POIs ───────────────────────────────────────────────────────────
|
|
const HIT_RADIUS = 14; // canvas display px
|
|
|
|
function handlePoiClick(p) {
|
|
// Check if clicking near an existing POI to remove it
|
|
const r = canvas.getBoundingClientRect();
|
|
const dispScale = r.width / img.naturalWidth; // nat px → display px
|
|
for (let i = pois.length - 1; i >= 0; i--) {
|
|
const dx = (pois[i].x - p.x) * dispScale;
|
|
const dy = (pois[i].y - p.y) * dispScale;
|
|
if (Math.sqrt(dx*dx + dy*dy) < HIT_RADIUS) {
|
|
pois.splice(i, 1);
|
|
updatePoiUI(); redraw(); return;
|
|
}
|
|
}
|
|
pois.push(p);
|
|
updatePoiUI(); redraw();
|
|
}
|
|
|
|
function undoPoi() { if (pois.length) { pois.pop(); updatePoiUI(); redraw(); } }
|
|
|
|
function updatePoiUI() {
|
|
document.getElementById('poi-count').textContent = pois.length + ' impact' + (pois.length !== 1 ? 's' : '');
|
|
document.getElementById('btn-compute').disabled = pois.length < 1;
|
|
}
|
|
|
|
// ── Step navigation ────────────────────────────────────────────────────────
|
|
function goStep(n) {
|
|
// Validate before advancing
|
|
if (n === 1) {
|
|
const distVal = parseFloat(document.getElementById('ref-dist').value);
|
|
const unitSel = document.getElementById('ref-unit').value;
|
|
if (!(refP1 && refP2 && distVal > 0)) { alert('Please draw the reference line and enter its distance.'); return; }
|
|
refMm = toMm(distVal, unitSel);
|
|
}
|
|
step = n;
|
|
updateStepUI(); redraw();
|
|
}
|
|
|
|
function updateStepUI() {
|
|
// Panels
|
|
for (let i = 0; i <= 3; i++) {
|
|
document.getElementById('panel-' + i).style.display = (i === step) ? '' : 'none';
|
|
}
|
|
// Step indicators
|
|
const labels = ['Reference line', 'Point of Aim', 'Points of Impact', 'Results'];
|
|
for (let i = 0; i <= 3; i++) {
|
|
const el = document.getElementById('si-' + i);
|
|
const num = el.querySelector('.si-num');
|
|
if (i < step) {
|
|
el.style.background = '#e8f5e9'; el.style.color = '#27ae60';
|
|
num.style.background = '#27ae60'; num.style.color = '#fff';
|
|
num.textContent = '✓';
|
|
} else if (i === step) {
|
|
el.style.background = '#f0f4ff'; el.style.color = '#1a1a2e';
|
|
num.style.background = '#1a1a2e'; num.style.color = '#fff';
|
|
num.textContent = i + 1;
|
|
} else {
|
|
el.style.background = ''; el.style.color = '#aaa';
|
|
num.style.background = '#e0e0e0'; num.style.color = '#888';
|
|
num.textContent = i + 1;
|
|
}
|
|
}
|
|
// Cursor
|
|
canvas.style.cursor = (step <= 2) ? 'crosshair' : 'default';
|
|
}
|
|
|
|
// ── Computation ────────────────────────────────────────────────────────────
|
|
function dist2(a, b) { return Math.sqrt((a.x-b.x)**2 + (a.y-b.y)**2); }
|
|
|
|
function toMm(val, unit) {
|
|
if (unit === 'cm') return val * 10;
|
|
if (unit === 'in') return val * 25.4;
|
|
return val;
|
|
}
|
|
|
|
function toMoa(sizeMm, distM) {
|
|
// true angular MOA
|
|
return Math.atan(sizeMm / (distM * 1000)) * (180 / Math.PI * 60);
|
|
}
|
|
|
|
function compute() {
|
|
const shootDistEl = document.getElementById('shoot-dist');
|
|
const shootUnitEl = document.getElementById('shoot-unit');
|
|
let distM = parseFloat(shootDistEl.value);
|
|
if (isNaN(distM) || distM <= 0) { alert('Enter a valid shooting distance first.'); shootDistEl.focus(); return; }
|
|
if (shootUnitEl.value === 'yd') distM *= 0.9144; // yards → metres
|
|
|
|
// Scale factor: pixels per mm
|
|
const refPxDist = dist2(refP1, refP2);
|
|
const pxPerMm = refPxDist / refMm;
|
|
|
|
// Convert POIs to mm relative to POA
|
|
const poisMm = pois.map(p => ({
|
|
x: (p.x - poa.x) / pxPerMm,
|
|
y: (p.y - poa.y) / pxPerMm,
|
|
}));
|
|
|
|
// Group centre
|
|
const cx = poisMm.reduce((s, p) => s + p.x, 0) / poisMm.length;
|
|
const cy = poisMm.reduce((s, p) => s + p.y, 0) / poisMm.length;
|
|
|
|
// Extreme Spread: max pairwise distance
|
|
let es = 0, esI = 0, esJ = 0;
|
|
for (let i = 0; i < poisMm.length; i++) {
|
|
for (let j = i + 1; j < poisMm.length; j++) {
|
|
const d = dist2(poisMm[i], poisMm[j]);
|
|
if (d > es) { es = d; esI = i; esJ = j; }
|
|
}
|
|
}
|
|
|
|
// Mean Radius: average distance from group centre
|
|
const mr = poisMm.reduce((s, p) => s + dist2(p, {x:cx,y:cy}), 0) / poisMm.length;
|
|
|
|
// POA → centre
|
|
const poaToCenter = dist2({x:0,y:0}, {x:cx,y:cy});
|
|
|
|
stats = {
|
|
shot_count: pois.length,
|
|
group_size_mm: es,
|
|
group_size_moa: distM > 0 ? toMoa(es, distM) : null,
|
|
mean_radius_mm: mr,
|
|
mean_radius_moa: distM > 0 ? toMoa(mr, distM) : null,
|
|
center_x_mm: cx, // + = right, - = left
|
|
center_y_mm: cy, // + = down, - = up
|
|
center_dist_mm: poaToCenter,
|
|
center_dist_moa: distM > 0 ? toMoa(poaToCenter, distM) : null,
|
|
shooting_distance_m: distM,
|
|
es_poi_indices: [esI, esJ],
|
|
};
|
|
|
|
renderResults();
|
|
goStep(3);
|
|
redraw();
|
|
}
|
|
|
|
function renderResults() {
|
|
if (!stats) return;
|
|
const f1 = v => (v != null ? v.toFixed(1) : '—');
|
|
const f2 = v => (v != null ? v.toFixed(2) : '—');
|
|
const sign = v => v >= 0 ? '+' : '';
|
|
const dir = (mm, axis) => {
|
|
if (axis === 'x') return mm > 0 ? 'right' : mm < 0 ? 'left' : 'center';
|
|
return mm > 0 ? 'low' : mm < 0 ? 'high' : 'center';
|
|
};
|
|
|
|
document.getElementById('results-box').innerHTML = `
|
|
<div class="stat-section">Group size</div>
|
|
<div class="stat-row"><span class="stat-label">Extreme Spread</span>
|
|
<span class="stat-val">${f2(stats.group_size_moa)} MOA</span></div>
|
|
<div class="stat-row"><span class="stat-label"></span>
|
|
<span class="stat-val" style="color:#666;font-weight:400;">${f1(stats.group_size_mm)} mm</span></div>
|
|
|
|
<div class="stat-section">Precision</div>
|
|
<div class="stat-row"><span class="stat-label">Mean Radius</span>
|
|
<span class="stat-val">${f2(stats.mean_radius_moa)} MOA</span></div>
|
|
<div class="stat-row"><span class="stat-label"></span>
|
|
<span class="stat-val" style="color:#666;font-weight:400;">${f1(stats.mean_radius_mm)} mm</span></div>
|
|
|
|
<div class="stat-section">Center vs POA</div>
|
|
<div class="stat-row"><span class="stat-label">Distance</span>
|
|
<span class="stat-val">${f2(stats.center_dist_moa)} MOA</span></div>
|
|
<div class="stat-row"><span class="stat-label">Horiz.</span>
|
|
<span class="stat-val">${f1(Math.abs(stats.center_x_mm))} mm ${dir(stats.center_x_mm,'x')}</span></div>
|
|
<div class="stat-row"><span class="stat-label">Vert.</span>
|
|
<span class="stat-val">${f1(Math.abs(stats.center_y_mm))} mm ${dir(stats.center_y_mm,'y')}</span></div>
|
|
|
|
<div class="stat-section">Info</div>
|
|
<div class="stat-row"><span class="stat-label">Shots</span>
|
|
<span class="stat-val">${stats.shot_count}</span></div>
|
|
<div class="stat-row"><span class="stat-label">@ distance</span>
|
|
<span class="stat-val">${stats.shooting_distance_m.toFixed(0)} m</span></div>
|
|
<div class="stat-row"><span class="stat-label">Clean barrel</span>
|
|
<span class="stat-val">${document.getElementById('clean-barrel').checked ? 'Yes' : 'No'}</span></div>
|
|
`;
|
|
}
|
|
|
|
// ── Drawing ────────────────────────────────────────────────────────────────
|
|
const COLORS = {
|
|
ref: '#2196f3',
|
|
poa: '#e53935',
|
|
poi: '#1565c0',
|
|
center: '#ff9800',
|
|
es: '#9c27b0',
|
|
mr: '#00897b',
|
|
};
|
|
|
|
function lineW(px) {
|
|
// px in display pixels → natural pixels
|
|
const r = canvas.getBoundingClientRect();
|
|
return px * (img.naturalWidth / r.width);
|
|
}
|
|
|
|
function redraw() {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.drawImage(img, 0, 0);
|
|
|
|
const lw = lineW(2);
|
|
const dotR = lineW(7);
|
|
|
|
// Reference line
|
|
if (refP1) {
|
|
const p2 = refClickStage === 1 && mousePos ? mousePos : refP2;
|
|
if (p2) {
|
|
ctx.save();
|
|
ctx.setLineDash([lineW(8), lineW(5)]);
|
|
ctx.strokeStyle = COLORS.ref; ctx.lineWidth = lw;
|
|
ctx.beginPath(); ctx.moveTo(refP1.x, refP1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
// Endpoints
|
|
drawDot(refP1, dotR * 0.7, COLORS.ref);
|
|
if (refP2) {
|
|
drawDot(refP2, dotR * 0.7, COLORS.ref);
|
|
// Label
|
|
const mid = { x: (refP1.x + refP2.x) / 2, y: (refP1.y + refP2.y) / 2 };
|
|
drawLabel(mid, refMm ? refMm.toFixed(0) + ' mm' : '?', COLORS.ref, lineW(12));
|
|
}
|
|
ctx.restore();
|
|
} else {
|
|
drawDot(refP1, dotR * 0.7, COLORS.ref);
|
|
}
|
|
}
|
|
|
|
// POA
|
|
if (poa) {
|
|
const r = dotR * 1.3;
|
|
ctx.save();
|
|
ctx.strokeStyle = COLORS.poa; ctx.lineWidth = lw * 1.5;
|
|
// Circle
|
|
ctx.beginPath(); ctx.arc(poa.x, poa.y, r, 0, Math.PI * 2); ctx.stroke();
|
|
// Crosshair
|
|
ctx.beginPath();
|
|
ctx.moveTo(poa.x - r * 1.6, poa.y); ctx.lineTo(poa.x - r * 0.4, poa.y);
|
|
ctx.moveTo(poa.x + r * 0.4, poa.y); ctx.lineTo(poa.x + r * 1.6, poa.y);
|
|
ctx.moveTo(poa.x, poa.y - r * 1.6); ctx.lineTo(poa.x, poa.y - r * 0.4);
|
|
ctx.moveTo(poa.x, poa.y + r * 0.4); ctx.lineTo(poa.x, poa.y + r * 1.6);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
// Group overlay (if computed)
|
|
if (stats && poa) {
|
|
const pxPerMm = dist2(refP1, refP2) / refMm;
|
|
const cx = poa.x + stats.center_x_mm * pxPerMm;
|
|
const cy = poa.y + stats.center_y_mm * pxPerMm;
|
|
|
|
// Mean radius circle
|
|
const mrPx = stats.mean_radius_mm * pxPerMm;
|
|
ctx.save();
|
|
ctx.setLineDash([lineW(6), lineW(4)]);
|
|
ctx.strokeStyle = COLORS.mr; ctx.lineWidth = lw;
|
|
ctx.beginPath(); ctx.arc(cx, cy, mrPx, 0, Math.PI*2); ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
ctx.restore();
|
|
|
|
// ES line between furthest pair
|
|
if (stats.shot_count >= 2) {
|
|
const [ei, ej] = stats.es_poi_indices;
|
|
ctx.save();
|
|
ctx.strokeStyle = COLORS.es; ctx.lineWidth = lw;
|
|
ctx.beginPath();
|
|
ctx.moveTo(pois[ei].x, pois[ei].y);
|
|
ctx.lineTo(pois[ej].x, pois[ej].y);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
// Group centre
|
|
drawDot({x:cx,y:cy}, dotR * 0.8, COLORS.center);
|
|
// Line POA → centre
|
|
if (dist2(poa, {x:cx,y:cy}) > dotR) {
|
|
ctx.save();
|
|
ctx.strokeStyle = COLORS.center; ctx.lineWidth = lw * 0.7;
|
|
ctx.setLineDash([lineW(4), lineW(3)]);
|
|
ctx.beginPath(); ctx.moveTo(poa.x, poa.y); ctx.lineTo(cx, cy); ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
// POIs
|
|
pois.forEach((p, i) => {
|
|
drawDot(p, dotR, COLORS.poi);
|
|
drawLabel(p, String(i + 1), '#fff', dotR * 0.85);
|
|
});
|
|
}
|
|
|
|
function drawDot(p, r, color) {
|
|
ctx.save();
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawLabel(p, text, color, size) {
|
|
ctx.save();
|
|
ctx.fillStyle = color;
|
|
ctx.font = `bold ${size}px system-ui,sans-serif`;
|
|
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, p.x, p.y);
|
|
ctx.restore();
|
|
}
|
|
|
|
// ── Save ───────────────────────────────────────────────────────────────────
|
|
async function saveAnnotations() {
|
|
const btn = document.getElementById('btn-save');
|
|
const status = document.getElementById('save-status');
|
|
btn.disabled = true;
|
|
status.textContent = 'Saving…';
|
|
|
|
const refDistVal = parseFloat(document.getElementById('ref-dist').value);
|
|
const refUnitVal = document.getElementById('ref-unit').value;
|
|
|
|
// Store coords as fractions of natural image size for portability
|
|
function toFrac(p) { return { x: p.x / img.naturalWidth, y: p.y / img.naturalHeight }; }
|
|
|
|
const payload = {
|
|
ref: { p1: toFrac(refP1), p2: toFrac(refP2), dist_value: refDistVal, dist_unit: refUnitVal, dist_mm: refMm },
|
|
poa: toFrac(poa),
|
|
pois: pois.map(toFrac),
|
|
shooting_distance_m: stats.shooting_distance_m,
|
|
clean_barrel: document.getElementById('clean-barrel').checked,
|
|
stats: stats,
|
|
};
|
|
|
|
try {
|
|
const resp = await fetch(SAVE_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (resp.ok) {
|
|
window.location.href = SESSION_URL;
|
|
} else {
|
|
throw new Error('Server error');
|
|
}
|
|
} catch {
|
|
status.style.color = '#e53935';
|
|
status.textContent = 'Save failed.';
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// ── Load existing annotations ──────────────────────────────────────────────
|
|
function loadExisting() {
|
|
// Always pre-fill shooting distance from session if available
|
|
if (SESSION_DIST_M) {
|
|
document.getElementById('shoot-dist').value = SESSION_DIST_M;
|
|
document.getElementById('shoot-unit').value = 'm';
|
|
}
|
|
|
|
if (!EXISTING || !EXISTING.ref) return;
|
|
const W = img.naturalWidth, H = img.naturalHeight;
|
|
function fromFrac(f) { return { x: f.x * W, y: f.y * H }; }
|
|
|
|
refP1 = fromFrac(EXISTING.ref.p1);
|
|
refP2 = fromFrac(EXISTING.ref.p2);
|
|
refMm = EXISTING.ref.dist_mm;
|
|
refClickStage = 2;
|
|
document.getElementById('ref-dist').value = EXISTING.ref.dist_value || '';
|
|
document.getElementById('ref-unit').value = EXISTING.ref.dist_unit || 'mm';
|
|
document.getElementById('ref-dist-row').style.display = '';
|
|
updateNextBtn0();
|
|
|
|
if (EXISTING.poa) poa = fromFrac(EXISTING.poa);
|
|
if (EXISTING.pois) pois = EXISTING.pois.map(fromFrac);
|
|
if (EXISTING.shooting_distance_m) {
|
|
document.getElementById('shoot-dist').value = EXISTING.shooting_distance_m.toFixed(0);
|
|
document.getElementById('shoot-unit').value = 'm';
|
|
}
|
|
if (EXISTING.clean_barrel) {
|
|
document.getElementById('clean-barrel').checked = true;
|
|
}
|
|
if (EXISTING.stats) {
|
|
stats = EXISTING.stats;
|
|
renderResults();
|
|
goStep(3);
|
|
} else if (pois.length > 0) {
|
|
goStep(2); updatePoiUI();
|
|
} else if (poa) {
|
|
goStep(2);
|
|
} else {
|
|
goStep(0);
|
|
}
|
|
redraw();
|
|
}
|
|
|
|
// ── Init ───────────────────────────────────────────────────────────────────
|
|
updateStepUI();
|
|
updatePoiUI();
|
|
</script>
|
|
|
|
{% endblock %}
|