feat: recognition server API, reference stream client, and live label memory
app.py becomes the recognition server: /v1 REST (health, catalog + template images, samples, recognize, classify, inspect, stock) plus /v1/stream over WebSocket, with token auth, size limits, one consistent error shape, and the legacy /api/sessions routes kept but now backed by the real engine instead of the fake adapter. Default bind is 127.0.0.1; LAN and TLS are opt-in. stream_client.py is the reference non-browser client (camera, video file, or a pan across a still) and prints results/s, RTT, detect time and label stability. Live quality work driven by measurements: - Labels are remembered per place: a fresh track covering a recently recognized product inherits the label instead of queueing for classification again. On a continuous pan this raised recognized products in view from 9/66 to 17-21/66 and cut time-to-first-label to ~1.6 s. - Classification priority is newest-first, because sorting by area spends the budget on products that are already leaving the frame. - Per-session classification concurrency raised to 12. hybrid_fast (ORB shortlist then hybrid) is implemented but NOT the default: it is 3.4x faster (13 ms vs 44 ms p50) yet agreed with hybrid on only 47% of accepted crops across 553 crops, far below the 98% adoption rule. It stays available for experiments; the numbers are in the commit and the benchmark. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
afb59b487f
commit
5f84f4f2a7
5 changed files with 1152 additions and 1170 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -47,7 +47,7 @@ STATE_RECOGNIZED = "recognized"
|
||||||
STATE_NEEDS_CONFIRMATION = "needs_confirmation"
|
STATE_NEEDS_CONFIRMATION = "needs_confirmation"
|
||||||
STATE_UNRECOGNIZED = "unrecognized"
|
STATE_UNRECOGNIZED = "unrecognized"
|
||||||
|
|
||||||
CLASSIFY_METHODS = ("surf", "orb", "brisk", "hybrid")
|
CLASSIFY_METHODS = ("surf", "orb", "brisk", "hybrid", "hybrid_fast")
|
||||||
|
|
||||||
|
|
||||||
class UndecodableImage(ValueError):
|
class UndecodableImage(ValueError):
|
||||||
|
|
@ -71,6 +71,12 @@ class EngineSettings:
|
||||||
# p50 while MPS takes ~112 ms with identical boxes, so CPU is the default.
|
# p50 while MPS takes ~112 ms with identical boxes, so CPU is the default.
|
||||||
# Override with --device / DORAYAKI_DEVICE (cpu | mps | cuda | cuda:0).
|
# Override with --device / DORAYAKI_DEVICE (cpu | mps | cuda | cuda:0).
|
||||||
device: Optional[str] = "cpu"
|
device: Optional[str] = "cpu"
|
||||||
|
# Live-mode defaults, measured with stream_client.py --pace result:
|
||||||
|
# hybrid classifies at ~44 ms/crop p50 and is the most accurate method
|
||||||
|
# (21/21 SKUs on image.jpg). hybrid_fast (ORB shortlist) is 3.4x faster but
|
||||||
|
# agreed with hybrid on only 47% of accepted crops, so it is NOT the default
|
||||||
|
# and stays available for experiments only (bench_engine.py --compare).
|
||||||
|
live_inflight: int = 12
|
||||||
imgsz: int = 640
|
imgsz: int = 640
|
||||||
classify_workers: int = 0 # 0 -> max(2, cpu_count - 2)
|
classify_workers: int = 0 # 0 -> max(2, cpu_count - 2)
|
||||||
live_method: str = "hybrid"
|
live_method: str = "hybrid"
|
||||||
|
|
@ -348,7 +354,10 @@ class RecognitionEngine:
|
||||||
method = (method or self.settings.live_method).lower()
|
method = (method or self.settings.live_method).lower()
|
||||||
if method not in CLASSIFY_METHODS:
|
if method not in CLASSIFY_METHODS:
|
||||||
raise UnknownMethod(method)
|
raise UnknownMethod(method)
|
||||||
scores, _ = self.matcher.score_crop(crop, method=method)
|
if method == "hybrid_fast":
|
||||||
|
scores, _ = self.matcher.score_crop_fast(crop)
|
||||||
|
else:
|
||||||
|
scores, _ = self.matcher.score_crop(crop, method=method)
|
||||||
return {str(sku): float(score) for sku, score in scores.items()}
|
return {str(sku): float(score) for sku, score in scores.items()}
|
||||||
|
|
||||||
def classify_boxes(
|
def classify_boxes(
|
||||||
|
|
@ -367,7 +376,10 @@ class RecognitionEngine:
|
||||||
return []
|
return []
|
||||||
ordered = sorted(boxes, key=lambda b: b["width"] * b["height"], reverse=True)
|
ordered = sorted(boxes, key=lambda b: b["width"] * b["height"], reverse=True)
|
||||||
crops = [self.crop(image, box["box"]) for box in ordered]
|
crops = [self.crop(image, box["box"]) for box in ordered]
|
||||||
futures = [self._executor.submit(self.matcher.score_crop, crop, method) for crop in crops]
|
if method == "hybrid_fast":
|
||||||
|
futures = [self._executor.submit(self.matcher.score_crop_fast, crop) for crop in crops]
|
||||||
|
else:
|
||||||
|
futures = [self._executor.submit(self.matcher.score_crop, crop, method) for crop in crops]
|
||||||
results: List[Dict[str, Any]] = []
|
results: List[Dict[str, Any]] = []
|
||||||
for box, future in zip(ordered, futures):
|
for box, future in zip(ordered, futures):
|
||||||
try:
|
try:
|
||||||
|
|
@ -392,7 +404,10 @@ class RecognitionEngine:
|
||||||
if method not in CLASSIFY_METHODS:
|
if method not in CLASSIFY_METHODS:
|
||||||
raise UnknownMethod(method)
|
raise UnknownMethod(method)
|
||||||
crop = self.crop(image, box)
|
crop = self.crop(image, box)
|
||||||
scores, details = self.matcher.score_crop(crop, method=method)
|
if method == "hybrid_fast":
|
||||||
|
scores, details = self.matcher.score_crop_fast(crop)
|
||||||
|
else:
|
||||||
|
scores, details = self.matcher.score_crop(crop, method=method)
|
||||||
decision = decide_identity(scores, details=details, method=method)
|
decision = decide_identity(scores, details=details, method=method)
|
||||||
identity = identity_payload(decision)
|
identity = identity_payload(decision)
|
||||||
|
|
||||||
|
|
@ -416,7 +431,7 @@ class RecognitionEngine:
|
||||||
if visualized_sku:
|
if visualized_sku:
|
||||||
try:
|
try:
|
||||||
visualization = self.matcher.generate_match_visualization(
|
visualization = self.matcher.generate_match_visualization(
|
||||||
crop, visualized_sku, method=method
|
crop, visualized_sku, method=method if method != "hybrid_fast" else "hybrid"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
visualization = None
|
visualization = None
|
||||||
|
|
@ -647,11 +662,12 @@ def settings_from_env(env: Optional[Dict[str, str]] = None) -> EngineSettings:
|
||||||
base,
|
base,
|
||||||
weights_path=env.get("DORAYAKI_WEIGHTS", base.weights_path),
|
weights_path=env.get("DORAYAKI_WEIGHTS", base.weights_path),
|
||||||
stock_dir=env.get("DORAYAKI_STOCK_DIR", base.stock_dir),
|
stock_dir=env.get("DORAYAKI_STOCK_DIR", base.stock_dir),
|
||||||
device=env.get("DORAYAKI_DEVICE") or None,
|
device=env.get("DORAYAKI_DEVICE") or base.device,
|
||||||
imgsz=int(env.get("DORAYAKI_IMGSZ", base.imgsz)),
|
imgsz=int(env.get("DORAYAKI_IMGSZ", base.imgsz)),
|
||||||
classify_workers=int(env.get("DORAYAKI_CLASSIFY_WORKERS", base.classify_workers)),
|
classify_workers=int(env.get("DORAYAKI_CLASSIFY_WORKERS", base.classify_workers)),
|
||||||
live_method=env.get("DORAYAKI_LIVE_METHOD", base.live_method),
|
live_method=env.get("DORAYAKI_LIVE_METHOD", base.live_method),
|
||||||
live_conf=float(env.get("DORAYAKI_LIVE_CONF", base.live_conf)),
|
live_conf=float(env.get("DORAYAKI_LIVE_CONF", base.live_conf)),
|
||||||
|
live_inflight=int(env.get("DORAYAKI_LIVE_INFLIGHT", base.live_inflight)),
|
||||||
detect_conf=float(env.get("DORAYAKI_DETECT_CONF", base.detect_conf)),
|
detect_conf=float(env.get("DORAYAKI_DETECT_CONF", base.detect_conf)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,76 @@ class ProductFeatureMatcher:
|
||||||
|
|
||||||
return scores, details
|
return scores, details
|
||||||
|
|
||||||
|
def score_crop_fast(
|
||||||
|
self,
|
||||||
|
crop_bgr: np.ndarray,
|
||||||
|
shortlist: int = 5
|
||||||
|
) -> Tuple[Dict[str, float], Dict[str, Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
ORB-shortlist then hybrid-score the top candidates only.
|
||||||
|
|
||||||
|
Same scoring formula as 'hybrid', but the expensive SURF/ORB/BRISK
|
||||||
|
descriptor matching runs on `shortlist` templates instead of all of them.
|
||||||
|
Every other template scores 0.0, so decide_identity() still ranks the
|
||||||
|
whole catalog. This is a live-mode speed path; validate it against
|
||||||
|
'hybrid' with bench_engine.py --compare before trusting it.
|
||||||
|
"""
|
||||||
|
if crop_bgr is None or crop_bgr.size == 0:
|
||||||
|
return {}, {}
|
||||||
|
crop_gray = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2GRAY)
|
||||||
|
extractors = self.thread_extractors()
|
||||||
|
|
||||||
|
orb = extractors["orb"]
|
||||||
|
kp_orb, des_orb = orb.detect_and_compute(crop_gray)
|
||||||
|
orb_scores: Dict[str, float] = {}
|
||||||
|
for sku_id, tmpl in self.db.templates.items():
|
||||||
|
kp_t = tmpl["features"]["orb"]["keypoints"]
|
||||||
|
des_t = tmpl["features"]["orb"]["descriptors"]
|
||||||
|
matches = self.match_descriptors(des_orb, des_t, orb.norm_type, ratio_thresh=0.75)
|
||||||
|
inliers, _, _ = self.compute_geometric_inliers(kp_orb, kp_t, matches)
|
||||||
|
denom = max(1, min(len(kp_orb), len(kp_t)))
|
||||||
|
orb_scores[sku_id] = inliers * 1.5 + (inliers / denom) * 80.0
|
||||||
|
|
||||||
|
candidates = sorted(orb_scores.items(), key=lambda kv: kv[1], reverse=True)[:shortlist]
|
||||||
|
|
||||||
|
scores: Dict[str, float] = {sku_id: 0.0 for sku_id in self.db.templates}
|
||||||
|
details: Dict[str, Dict[str, Any]] = {}
|
||||||
|
sub_methods = ["surf", "orb", "brisk"]
|
||||||
|
weights = {"surf": 0.45, "orb": 0.25, "brisk": 0.30}
|
||||||
|
query_features = {}
|
||||||
|
for sm in sub_methods:
|
||||||
|
kp_q, des_q = extractors[sm].detect_and_compute(crop_gray)
|
||||||
|
query_features[sm] = (kp_q, des_q)
|
||||||
|
|
||||||
|
for sku_id, _ in candidates:
|
||||||
|
tmpl = self.db.templates[sku_id]
|
||||||
|
composite = 0.0
|
||||||
|
sku_sub_details = {}
|
||||||
|
for sm in sub_methods:
|
||||||
|
kp_q, des_q = query_features[sm]
|
||||||
|
kp_t = tmpl["features"][sm]["keypoints"]
|
||||||
|
des_t = tmpl["features"][sm]["descriptors"]
|
||||||
|
matches = self.match_descriptors(des_q, des_t, extractors[sm].norm_type, ratio_thresh=0.75)
|
||||||
|
inliers, _, _ = self.compute_geometric_inliers(kp_q, kp_t, matches)
|
||||||
|
denom = max(1, min(len(kp_q), len(kp_t)))
|
||||||
|
sub_score = inliers * 1.5 + (inliers / denom) * 80.0
|
||||||
|
composite += weights[sm] * sub_score
|
||||||
|
sku_sub_details[sm] = {
|
||||||
|
"inliers": inliers,
|
||||||
|
"matches": len(matches),
|
||||||
|
"kp_q_count": len(kp_q),
|
||||||
|
"kp_t_count": len(kp_t),
|
||||||
|
"sub_score": round(sub_score, 2),
|
||||||
|
}
|
||||||
|
scores[sku_id] = composite
|
||||||
|
details[sku_id] = {
|
||||||
|
"score": composite,
|
||||||
|
"sub_details": sku_sub_details,
|
||||||
|
"kp_query": query_features["surf"][0],
|
||||||
|
"kp_tmpl": tmpl["features"]["surf"]["keypoints"],
|
||||||
|
}
|
||||||
|
return scores, details
|
||||||
|
|
||||||
def classify_crop(
|
def classify_crop(
|
||||||
self,
|
self,
|
||||||
crop_bgr: np.ndarray,
|
crop_bgr: np.ndarray,
|
||||||
|
|
@ -295,16 +365,19 @@ class ProductFeatureMatcher:
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Classifies a product crop against all SKU templates.
|
Classifies a product crop against all SKU templates.
|
||||||
Methods supported: 'surf', 'orb', 'brisk', 'hybrid'.
|
Methods supported: 'surf', 'orb', 'brisk', 'hybrid', 'hybrid_fast'.
|
||||||
"""
|
"""
|
||||||
if crop_bgr is None or crop_bgr.size == 0:
|
if crop_bgr is None or crop_bgr.size == 0:
|
||||||
return {"status": "error", "message": "Empty crop image"}
|
return {"status": "error", "message": "Empty crop image"}
|
||||||
|
|
||||||
method = method.lower()
|
method = method.lower()
|
||||||
if method not in ["surf", "orb", "brisk", "hybrid"]:
|
if method == "hybrid_fast":
|
||||||
|
scores, details = self.score_crop_fast(crop_bgr)
|
||||||
|
elif method in ["surf", "orb", "brisk", "hybrid"]:
|
||||||
|
scores, details = self.score_crop(crop_bgr, method=method)
|
||||||
|
else:
|
||||||
return {"status": "error", "message": f"Unknown method: {method}"}
|
return {"status": "error", "message": f"Unknown method: {method}"}
|
||||||
|
|
||||||
scores, details = self.score_crop(crop_bgr, method=method)
|
|
||||||
ranked = decide_identity(scores, details=details, method=method)
|
ranked = decide_identity(scores, details=details, method=method)
|
||||||
return ranked
|
return ranked
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,12 @@ from tracking import SessionTracker # noqa: E402
|
||||||
EMPTY_SLOT_HISTORY = 5
|
EMPTY_SLOT_HISTORY = 5
|
||||||
EMPTY_SLOT_MIN_HITS = 3
|
EMPTY_SLOT_MIN_HITS = 3
|
||||||
EMPTY_SLOT_IOU = 0.3
|
EMPTY_SLOT_IOU = 0.3
|
||||||
|
# Label memory: a recognized product keeps its label for any track that covers
|
||||||
|
# the same place soon after, so panning does not re-classify one product many
|
||||||
|
# times just because the tracker re-issued its id.
|
||||||
|
LABEL_MEMORY_IOU = 0.55
|
||||||
|
LABEL_MEMORY_FRAMES = 45
|
||||||
|
LABEL_MEMORY_MAX = 200
|
||||||
STATS_WINDOW = 30
|
STATS_WINDOW = 30
|
||||||
# A recognized label is only trustworthy while the box still covers the product
|
# A recognized label is only trustworthy while the box still covers the product
|
||||||
# it was measured on. Below this IoU against the box at label time, the track has
|
# it was measured on. Below this IoU against the box at label time, the track has
|
||||||
|
|
@ -60,7 +66,7 @@ class LiveSettings:
|
||||||
max_attempts: int = 3
|
max_attempts: int = 3
|
||||||
retry_gap: int = 8
|
retry_gap: int = 8
|
||||||
area_growth: float = 1.3
|
area_growth: float = 1.3
|
||||||
max_inflight_per_session: int = 6
|
max_inflight_per_session: int = 12
|
||||||
edge_margin_ratio: float = 0.015
|
edge_margin_ratio: float = 0.015
|
||||||
glare_fraction: float = 0.02
|
glare_fraction: float = 0.02
|
||||||
glare_pixel: int = 245
|
glare_pixel: int = 245
|
||||||
|
|
@ -170,6 +176,9 @@ class LiveSession:
|
||||||
self._slot_counter = 0
|
self._slot_counter = 0
|
||||||
self._catalog_by_sku: Dict[str, Dict[str, Any]] = {}
|
self._catalog_by_sku: Dict[str, Dict[str, Any]] = {}
|
||||||
self._catalog_version = ""
|
self._catalog_version = ""
|
||||||
|
self._label_memory: "collections.deque[Dict[str, Any]]" = collections.deque(
|
||||||
|
maxlen=LABEL_MEMORY_MAX
|
||||||
|
)
|
||||||
self._last_quality: Dict[str, Any] = {}
|
self._last_quality: Dict[str, Any] = {}
|
||||||
self._created_at = time.time()
|
self._created_at = time.time()
|
||||||
|
|
||||||
|
|
@ -257,6 +266,7 @@ class LiveSession:
|
||||||
self._relabel_count.clear()
|
self._relabel_count.clear()
|
||||||
self._restart_count.clear()
|
self._restart_count.clear()
|
||||||
self._slot_history.clear()
|
self._slot_history.clear()
|
||||||
|
self._label_memory.clear()
|
||||||
self._slots = []
|
self._slots = []
|
||||||
self._latest_image = None
|
self._latest_image = None
|
||||||
self._latest_frame_id = None
|
self._latest_frame_id = None
|
||||||
|
|
@ -368,6 +378,7 @@ class LiveSession:
|
||||||
self._slots = self._persist_slots(raw_slots)
|
self._slots = self._persist_slots(raw_slots)
|
||||||
|
|
||||||
quality = self._quality(image, self._tracks)
|
quality = self._quality(image, self._tracks)
|
||||||
|
self._inherit_labels(seq)
|
||||||
|
|
||||||
# Collect the classification work under the lock; submit it after
|
# Collect the classification work under the lock; submit it after
|
||||||
# releasing it. add_done_callback can run the callback inline when the
|
# releasing it. add_done_callback can run the callback inline when the
|
||||||
|
|
@ -400,6 +411,54 @@ class LiveSession:
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _inherit_labels(self, seq: int) -> None:
|
||||||
|
"""Give a fresh track the label of a recently recognized product it covers.
|
||||||
|
|
||||||
|
The same physical product can be tracked as several ids while the camera
|
||||||
|
pans. Remembering where a label was measured means each product is
|
||||||
|
classified once and its neighbours inherit the result instead of waiting
|
||||||
|
in the classification queue again.
|
||||||
|
"""
|
||||||
|
if not self._label_memory:
|
||||||
|
return
|
||||||
|
for track in self._tracks.values():
|
||||||
|
if track.state != STATE_DETECTED or track.confirmed:
|
||||||
|
continue
|
||||||
|
for entry in reversed(self._label_memory):
|
||||||
|
if seq - entry["seq"] > LABEL_MEMORY_FRAMES:
|
||||||
|
break
|
||||||
|
if _iou(track.box, entry["box"]) >= LABEL_MEMORY_IOU:
|
||||||
|
track.state = STATE_RECOGNIZED
|
||||||
|
track.confirmed = True
|
||||||
|
track.label_box = list(track.box)
|
||||||
|
track.identity = {
|
||||||
|
"sku_id": entry["sku_id"],
|
||||||
|
"confidence": entry["confidence"],
|
||||||
|
"score": entry["score"],
|
||||||
|
"attempts": 0,
|
||||||
|
"inherited": True,
|
||||||
|
}
|
||||||
|
# An inherited label is a guess from the same place, not a
|
||||||
|
# measurement of this object: count it as re-verified so a
|
||||||
|
# later correction is auditable rather than a silent flip.
|
||||||
|
track.relabels = self._relabel_count.get(track.track_id, 0) + 1
|
||||||
|
self._relabel_count[track.track_id] = track.relabels
|
||||||
|
break
|
||||||
|
|
||||||
|
def _remember_label(self, track: TrackState, box: List[int], seq: int) -> None:
|
||||||
|
"""Must be called with the session lock held."""
|
||||||
|
if track.state != STATE_RECOGNIZED or not track.identity:
|
||||||
|
return
|
||||||
|
self._label_memory.append(
|
||||||
|
{
|
||||||
|
"box": list(box),
|
||||||
|
"sku_id": track.identity.get("sku_id"),
|
||||||
|
"confidence": track.identity.get("confidence"),
|
||||||
|
"score": track.identity.get("score"),
|
||||||
|
"seq": seq,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def _drop_stale_label(self, track: TrackState) -> None:
|
def _drop_stale_label(self, track: TrackState) -> None:
|
||||||
"""Reset a settled label when the box has drifted off its product.
|
"""Reset a settled label when the box has drifted off its product.
|
||||||
|
|
||||||
|
|
@ -465,7 +524,10 @@ class LiveSession:
|
||||||
continue
|
continue
|
||||||
eligible.append(track)
|
eligible.append(track)
|
||||||
|
|
||||||
eligible.sort(key=lambda track: track.area(), reverse=True)
|
# Newest first: while a camera pans, the products still worth labelling
|
||||||
|
# are the ones that just entered the frame. Sorting by area (the obvious
|
||||||
|
# default) spends the budget on products that are already leaving.
|
||||||
|
eligible.sort(key=lambda track: (track.first_seq, -track.area()))
|
||||||
planned: List[Tuple[TrackState, Any, int]] = []
|
planned: List[Tuple[TrackState, Any, int]] = []
|
||||||
for track in eligible[:capacity]:
|
for track in eligible[:capacity]:
|
||||||
crop = self.engine.crop(image, track.raw_box)
|
crop = self.engine.crop(image, track.raw_box)
|
||||||
|
|
@ -561,6 +623,7 @@ class LiveSession:
|
||||||
"score": decision.get("score"),
|
"score": decision.get("score"),
|
||||||
"attempts": track.attempts,
|
"attempts": track.attempts,
|
||||||
}
|
}
|
||||||
|
self._remember_label(track, submitted_box, self._seq)
|
||||||
elif track.attempts >= self.settings.max_attempts:
|
elif track.attempts >= self.settings.max_attempts:
|
||||||
track.state = (
|
track.state = (
|
||||||
STATE_NEEDS_CONFIRMATION
|
STATE_NEEDS_CONFIRMATION
|
||||||
|
|
@ -612,6 +675,7 @@ class LiveSession:
|
||||||
"first_seen_seq": track.first_seq,
|
"first_seen_seq": track.first_seq,
|
||||||
"relabels": track.relabels,
|
"relabels": track.relabels,
|
||||||
"restarts": track.restarts,
|
"restarts": track.restarts,
|
||||||
|
"inherited": bool(track.identity and track.identity.get("inherited")),
|
||||||
"identity": identity,
|
"identity": identity,
|
||||||
}
|
}
|
||||||
if product is not None:
|
if product is not None:
|
||||||
|
|
|
||||||
283
recognization/code/stream_client.py
Normal file
283
recognization/code/stream_client.py
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
"""
|
||||||
|
Reference live client for the Dorayaki recognition server.
|
||||||
|
|
||||||
|
Streams frames over WebSocket /v1/stream and prints what the server sees. This
|
||||||
|
is the documented example for capture sources that are not a browser: a cart
|
||||||
|
camera, a wearable camera, a rail camera, a robot.
|
||||||
|
|
||||||
|
Usage (from recognization/):
|
||||||
|
.venv/bin/python code/stream_client.py --source image.jpg --pan 12 --seconds 20
|
||||||
|
.venv/bin/python code/stream_client.py --source 0 # webcam index
|
||||||
|
.venv/bin/python code/stream_client.py --source clip.mp4 --seconds 30
|
||||||
|
.venv/bin/python code/stream_client.py --url ws://host:8080/v1/stream --token SECRET
|
||||||
|
|
||||||
|
Frame protocol: 4-byte big-endian frame id + JPEG bytes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
project_root = os.path.dirname(current_dir)
|
||||||
|
if os.path.exists(os.path.join(project_root, "stock")):
|
||||||
|
os.chdir(project_root)
|
||||||
|
if current_dir not in sys.path:
|
||||||
|
sys.path.insert(0, current_dir)
|
||||||
|
|
||||||
|
import cv2 # noqa: E402
|
||||||
|
|
||||||
|
from websockets.sync.client import connect # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def percentile(values: List[float], pct: float) -> float:
|
||||||
|
if not values:
|
||||||
|
return 0.0
|
||||||
|
ordered = sorted(values)
|
||||||
|
index = min(len(ordered) - 1, max(0, int(round((pct / 100.0) * (len(ordered) - 1)))))
|
||||||
|
return ordered[index]
|
||||||
|
|
||||||
|
|
||||||
|
class FrameSource:
|
||||||
|
"""Yields BGR frames from a camera, a video file, or a still image pan."""
|
||||||
|
|
||||||
|
def __init__(self, source: str, *, width: int, pan_step: int, pan_width: int = 1280):
|
||||||
|
self.source = source
|
||||||
|
self.width = width
|
||||||
|
self.pan_step = pan_step
|
||||||
|
self.pan_width = pan_width
|
||||||
|
self.capture: Optional[cv2.VideoCapture] = None
|
||||||
|
self.image = None
|
||||||
|
self.pan_x = 0
|
||||||
|
self.pan_direction = 1
|
||||||
|
if source.isdigit():
|
||||||
|
self.capture = cv2.VideoCapture(int(source))
|
||||||
|
elif os.path.isfile(source) and source.lower().endswith((".mp4", ".mov", ".avi", ".mkv", ".webm")):
|
||||||
|
self.capture = cv2.VideoCapture(source)
|
||||||
|
else:
|
||||||
|
self.image = cv2.imread(source)
|
||||||
|
if self.image is None:
|
||||||
|
raise SystemExit(f"cannot read source {source}")
|
||||||
|
|
||||||
|
def frame(self, index: int):
|
||||||
|
if self.image is not None:
|
||||||
|
height, width = self.image.shape[:2]
|
||||||
|
window_w = min(self.pan_width, width)
|
||||||
|
window_h = min(int(window_w * 9 / 16), height)
|
||||||
|
max_x = max(0, width - window_w)
|
||||||
|
x = min(self.pan_x, max_x)
|
||||||
|
y = max(0, (height - window_h) // 2)
|
||||||
|
window = self.image[y:y + window_h, x:x + window_w]
|
||||||
|
self.pan_x += self.pan_direction * self.pan_step
|
||||||
|
if self.pan_x >= max_x or self.pan_x <= 0:
|
||||||
|
self.pan_direction *= -1
|
||||||
|
return window
|
||||||
|
if self.capture is not None:
|
||||||
|
ok, frame = self.capture.read()
|
||||||
|
if not ok: # loop video files
|
||||||
|
self.capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||||
|
ok, frame = self.capture.read()
|
||||||
|
if not ok:
|
||||||
|
return None
|
||||||
|
return frame
|
||||||
|
return None
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
if self.capture is not None:
|
||||||
|
self.capture.release()
|
||||||
|
|
||||||
|
|
||||||
|
def encode(frame, width: int, quality: int) -> bytes:
|
||||||
|
height, current_width = frame.shape[:2]
|
||||||
|
if width and current_width > width:
|
||||||
|
scale = width / float(current_width)
|
||||||
|
frame = cv2.resize(frame, (int(width), int(round(height * scale))))
|
||||||
|
ok, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError("JPEG encode failed")
|
||||||
|
return buffer.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Dorayaki live stream client")
|
||||||
|
parser.add_argument("--url", default="ws://127.0.0.1:8080/v1/stream")
|
||||||
|
parser.add_argument("--token", default=os.environ.get("DORAYAKI_API_TOKEN"))
|
||||||
|
parser.add_argument("--source", default="image.jpg", help="camera index, video path, or image path")
|
||||||
|
parser.add_argument("--pan", type=int, default=12, help="pixels per frame when panning a still image")
|
||||||
|
parser.add_argument("--seconds", type=float, default=20.0)
|
||||||
|
parser.add_argument("--width", type=int, default=1280, help="send width")
|
||||||
|
parser.add_argument("--quality", type=int, default=75)
|
||||||
|
parser.add_argument("--max-inflight", type=int, default=1)
|
||||||
|
parser.add_argument("--pace", choices=["send", "result"], default="send",
|
||||||
|
help="'send': keep the socket busy (frames drop server-side); "
|
||||||
|
"'result': send the next frame only after a result, so the "
|
||||||
|
"pan stays continuous (the mode to use for stability checks)")
|
||||||
|
parser.add_argument("--method", default=None, help="surf | orb | brisk | hybrid")
|
||||||
|
parser.add_argument("--json", action="store_true", help="print a JSON summary instead of the table")
|
||||||
|
parser.add_argument("--quiet", action="store_true", help="only print the final summary")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
url = args.url
|
||||||
|
if args.token and "token=" not in url:
|
||||||
|
url = f"{url}{'&' if '?' in url else '?'}token={args.token}"
|
||||||
|
|
||||||
|
source = FrameSource(args.source, width=args.width, pan_step=args.pan)
|
||||||
|
rtts: List[float] = []
|
||||||
|
detect_times: List[float] = []
|
||||||
|
frames_sent = 0
|
||||||
|
results = 0
|
||||||
|
drops = 0
|
||||||
|
first_label: Dict[int, float] = {}
|
||||||
|
label_latencies: List[float] = []
|
||||||
|
labels: Dict[int, str] = {}
|
||||||
|
label_changes = 0
|
||||||
|
unexplained_changes = 0
|
||||||
|
relabel_seen: Dict[int, int] = {}
|
||||||
|
restart_seen: Dict[int, int] = {}
|
||||||
|
inherited_seen: Dict[int, bool] = {}
|
||||||
|
seen_tracks: set = set()
|
||||||
|
last_summary: Dict[str, Any] = {}
|
||||||
|
started = time.perf_counter()
|
||||||
|
in_flight: Dict[int, float] = {}
|
||||||
|
pending_frame: Optional[int] = None
|
||||||
|
|
||||||
|
with connect(url, max_size=8 * 1024 * 1024) as socket:
|
||||||
|
hello = json.loads(socket.recv(timeout=10))
|
||||||
|
if hello.get("type") != "hello":
|
||||||
|
raise SystemExit(f"unexpected first message: {hello}")
|
||||||
|
if not args.quiet:
|
||||||
|
print(f"hello: session {hello['session_id']} model {hello['model']['version']} "
|
||||||
|
f"device {hello['model']['device']} config {hello['config']}")
|
||||||
|
if args.method:
|
||||||
|
socket.send(json.dumps({"type": "config", "method": args.method}))
|
||||||
|
ack = json.loads(socket.recv(timeout=10))
|
||||||
|
if not args.quiet:
|
||||||
|
print(f"config ack: {ack.get('config')}")
|
||||||
|
|
||||||
|
def consume(message: Dict[str, Any]) -> None:
|
||||||
|
"""Fold one result into the running metrics."""
|
||||||
|
nonlocal results, last_summary, label_changes, unexplained_changes
|
||||||
|
if message.get("type") != "result":
|
||||||
|
if not args.quiet:
|
||||||
|
print(f"server message: {message}")
|
||||||
|
return
|
||||||
|
received_at = time.perf_counter()
|
||||||
|
sent_at = in_flight.pop(message["frame_id"], None)
|
||||||
|
if sent_at is not None:
|
||||||
|
rtts.append((received_at - sent_at) * 1000.0)
|
||||||
|
detect_times.append(message["timing_ms"]["detect"])
|
||||||
|
results += 1
|
||||||
|
last_summary = message["summary"]
|
||||||
|
now = time.perf_counter()
|
||||||
|
for track in message["tracks"]:
|
||||||
|
track_id = track["track_id"]
|
||||||
|
seen_tracks.add(track_id)
|
||||||
|
identity = track["identity"]
|
||||||
|
if identity["state"] != "recognized":
|
||||||
|
continue
|
||||||
|
sku = identity["sku_id"]
|
||||||
|
relabels = track.get("relabels", 0)
|
||||||
|
restarts = track.get("restarts", 0)
|
||||||
|
if restarts > restart_seen.get(track_id, 0):
|
||||||
|
# The id was restarted: earlier labels describe another object.
|
||||||
|
labels.pop(track_id, None)
|
||||||
|
first_label.pop(track_id, None)
|
||||||
|
inherited = bool(track.get("inherited"))
|
||||||
|
if track_id not in first_label:
|
||||||
|
first_label[track_id] = now
|
||||||
|
if not inherited:
|
||||||
|
label_latencies.append((now - started) * 1000.0)
|
||||||
|
elif labels.get(track_id) != sku:
|
||||||
|
# Only a measured-to-measured change on the same object is a
|
||||||
|
# label change; inherited labels describe the same product
|
||||||
|
# under a new track id.
|
||||||
|
if not inherited and not inherited_seen.get(track_id):
|
||||||
|
label_changes += 1
|
||||||
|
if relabels <= relabel_seen.get(track_id, 0):
|
||||||
|
unexplained_changes += 1
|
||||||
|
labels[track_id] = sku
|
||||||
|
inherited_seen[track_id] = inherited
|
||||||
|
relabel_seen[track_id] = relabels
|
||||||
|
restart_seen[track_id] = restarts
|
||||||
|
if not args.quiet and results % 10 == 0:
|
||||||
|
print(f"f{message['seq']:04d} tracks={len(message['tracks']):3d} "
|
||||||
|
f"recognized={last_summary['recognized']:3d} "
|
||||||
|
f"unconfirmed={last_summary.get('unconfirmed', 0):3d} "
|
||||||
|
f"detect={message['timing_ms']['detect']:5.1f}ms "
|
||||||
|
f"rtt={rtts[-1]:6.1f}ms fps={results / (now - started):5.2f}")
|
||||||
|
|
||||||
|
frame_id = 0
|
||||||
|
index = 0
|
||||||
|
while time.perf_counter() - started < args.seconds:
|
||||||
|
frame = source.frame(index)
|
||||||
|
if frame is None:
|
||||||
|
break
|
||||||
|
index += 1
|
||||||
|
frame_id = (frame_id + 1) & 0xFFFFFFFF
|
||||||
|
payload = encode(frame, args.width, args.quality)
|
||||||
|
if len(in_flight) >= args.max_inflight:
|
||||||
|
if pending_frame is not None:
|
||||||
|
drops += 1
|
||||||
|
pending_frame = frame_id
|
||||||
|
socket.send(frame_id.to_bytes(4, "big") + payload, text=False)
|
||||||
|
frames_sent += 1
|
||||||
|
else:
|
||||||
|
socket.send(frame_id.to_bytes(4, "big") + payload, text=False)
|
||||||
|
frames_sent += 1
|
||||||
|
in_flight[frame_id] = time.perf_counter()
|
||||||
|
if args.pace == "result":
|
||||||
|
# One frame in flight: the next frame is captured only after this
|
||||||
|
# one was answered, so the pan stays continuous.
|
||||||
|
while in_flight:
|
||||||
|
try:
|
||||||
|
consume(json.loads(socket.recv(timeout=5)))
|
||||||
|
except TimeoutError:
|
||||||
|
print("timeout waiting for a result")
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
consume(json.loads(socket.recv(timeout=5)))
|
||||||
|
except TimeoutError:
|
||||||
|
print("timeout waiting for a result")
|
||||||
|
break
|
||||||
|
|
||||||
|
source.release()
|
||||||
|
elapsed = time.perf_counter() - started
|
||||||
|
summary = {
|
||||||
|
"seconds": round(elapsed, 1),
|
||||||
|
"frames_sent": frames_sent,
|
||||||
|
"results": results,
|
||||||
|
"results_per_s": round(results / elapsed, 2) if elapsed else 0,
|
||||||
|
"frames_dropped_by_client": drops,
|
||||||
|
"rtt_ms_p50": round(statistics.median(rtts), 1) if rtts else None,
|
||||||
|
"rtt_ms_p95": round(percentile(rtts, 95), 1) if rtts else None,
|
||||||
|
"server_detect_ms_p50": round(statistics.median(detect_times), 1) if detect_times else None,
|
||||||
|
"server_detect_ms_p95": round(percentile(detect_times, 95), 1) if detect_times else None,
|
||||||
|
"tracks_seen": len(seen_tracks),
|
||||||
|
"tracks_labelled": len(labels),
|
||||||
|
"label_changes": label_changes,
|
||||||
|
"unexplained_label_changes": unexplained_changes,
|
||||||
|
"first_label_ms_p50": round(statistics.median(label_latencies), 0) if label_latencies else None,
|
||||||
|
"final_summary": last_summary,
|
||||||
|
}
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(summary, indent=2))
|
||||||
|
else:
|
||||||
|
print("\n--- stream summary ---")
|
||||||
|
for key, value in summary.items():
|
||||||
|
if key != "final_summary":
|
||||||
|
print(f"{key:24s} {value}")
|
||||||
|
print(f"{'final_summary':24s} {json.dumps(last_summary, ensure_ascii=False)}")
|
||||||
|
if results == 0:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue