ppbbww

Pillar Point boats, birds, and waves watcher
git clone git@abtrout.com:ppbbww.git
Log | Files | Refs | README | LICENSE

commit 7529d63636057827e811c4551cbb2b68c9752d6a
parent d1624acf2b308939620532bf9aa8c9147c939713
Author: david cochran <about.trout@gmail.com>
Date:   Mon,  9 Sep 2024 19:46:00 -0700

various improvements to ppbbww binary

Diffstat:
Appboatwatch/ppbbww.py | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dppboatwatch/ppbw.py | 155-------------------------------------------------------------------------------
Mpyproject.toml | 2+-
3 files changed, 158 insertions(+), 156 deletions(-)

diff --git a/ppboatwatch/ppbbww.py b/ppboatwatch/ppbbww.py @@ -0,0 +1,157 @@ +import argparse +import asyncio +import json +import logging +import random +import tempfile +import time +import os +import sys + +from datetime import datetime +from slack_sdk.web.async_client import AsyncWebClient +from PIL import Image, ImageDraw, ImageFont + +from .archive import Archive +from .stream_sampler import StreamSampler +from .object_detector import ObjectDetector + + +async def sample_stream(frames_q, sampler, min_delay, max_delay, day_start, day_end): + fail_count, max_failures = 0, 5 + while True: + try: + frames = await sampler.get_recent_frames() + logging.warning(f"[sample_stream]: Extracted {len(frames)} recent frames") + assert len(frames) > 0, "No frames extracted" + await frames_q.put(frames[0].path) + for frame in frames[1:]: # only keep 1 frame + os.remove(frame) + fail_count = 0 # this request succeeded, reset fail counter. + except Exception as ex: + logging.error(f"[sample_stream]: Failed to get_recent_frames: {ex}") + fail_count += 1 + if fail_count >= max_failures: + logging.warning("[sample_stream]: Too many download failures. Killing program!") + sys.exit(1) + + if day_start <= datetime.now().time() <= day_end: + delay = random.randint(min_delay, max_delay) + else: + logging.warning(f"[sample_stream]: Day has ended!") + delay = 86400 - (datetime.combine(datetime.today(), day_end) - datetime.combine(datetime.today(), day_start)).total_seconds() + logging.warning(f"[sample_stream]: Sleeping {delay} seconds") + await asyncio.sleep(delay) + + +async def find_matches(frames_q, archive_q, announce_q): + detector = ObjectDetector(thresh=0.80) + while True: + # Get filenames from the queue and run object detector. + frame_file = await frames_q.get() + image = Image.open(frame_file) + matches = list(filter_matches(detector.find(image))) + if len(matches) == 0: + os.remove(frame_file) + logging.warning(f"[find_matches]: No matches; cleaned up") + continue + # Archive (and announce) the frame and matches. + await archive_q.put((frame_file, matches)) + await announce_q.put((frame_file, matches)) + + +def filter_matches(matches): + matches = list(matches) + num_boxes = len(matches) + for label, score, box in matches: + box = list(box) + # Skip matches at the bottom of the image area (rocks). + if box[1] > 600: + continue + # Skip common but uninteresting cases based on size and label. + box_size = (box[2] - box[0]) * (box[3] - box[1]) + if num_boxes < 5 and box_size < 500: + logging.info(f"Skipping small box (box_size={box_size})") + continue + if num_boxes < 5 and label == "boat" and box_size < 3000: + logging.info(f"Skipping small boat (box_size={box_size})") + continue + if num_boxes < 5 and label == "bird" and box_size < 1000: + logging.info(f"Skipping small bird (box_size={box_size})") + continue + # Return everything else. + yield (label, score, box) + + +async def archive_matches(archive_q, archive): + while True: + file, matches = await archive_q.get() + logging.warning(f"[archive_matches] Archiving {file}: {matches}") + ts = file.split("/")[-1].split("-")[0] + for label, score, box in matches: + archive.add_match(ts=ts, filename=file, label=label, score=score, box=box) + + +async def announce_matches(announce_q, client): + last_announce_ts, thread_ts = 0, None + mkdate = lambda ts: datetime.fromtimestamp(ts).date() + while True: + file, matches = await announce_q.get() + last_date, now_date = mkdate(last_announce_ts), mkdate(time.time()) + thread_ts = await post_match( + client, file, thread_ts if last_date == now_date else None + ) + logging.warning(f"[annnounce_matches] Posted match at thread_ts {thread_ts}") + last_announce_ts = time.time() + + +async def post_match(client, frame_file, thread_ts): + chan_name, chan_id = "#boatwatch", "C06LMBRNV8U" + frame_ts = frame_file.split("/")[-1].split("-")[0] + with open(frame_file, "rb") as f: + res = await client.files_upload( + channels=chan_name, + thread_ts=thread_ts, + file=f, + filename=frame_ts, + ) + if not res["ok"]: + logging.error(f"Failed to post_match: {res}") + try: + return res.get("file").get("shares").get("private").get(chan_id)[0].get("ts") + except Exception as ex: + logging.error(f"Failed to post_match: invalid response {ex}") + logging.error(res) + + +async def main_task(args): + archive = Archive(args.db_file) + client = AsyncWebClient(token=os.environ["SLACK_API_TOKEN"]) + sampler = StreamSampler("mavericksov", args.data_dir) + + frames_q = asyncio.Queue() # frames that should be inspected. + archive_q = asyncio.Queue() # matches that should be archived. + announce_q = asyncio.Queue() # matches that should be announced in Slack. + + async with asyncio.TaskGroup() as tg: + tg.create_task(sample_stream(frames_q, sampler, args.min_delay, args.max_delay, args.day_start, args.day_end)) + tg.create_task(find_matches(frames_q, archive_q, announce_q)) + tg.create_task(archive_matches(archive_q, archive)) + tg.create_task(announce_matches(announce_q, client)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("-d", "--data-dir", default="./data", type=str) + parser.add_argument("-f", "--db-file", default="./archive.db", type=str) + parser.add_argument("-v", "--verbose", action="store_true") + parser.add_argument("--min-delay", default=5, type=int) + parser.add_argument("--max-delay", default=30, type=int) + parser.add_argument("--day-start", default="6:00", type=lambda s: datetime.strptime(s, "%H:%M").time()) + parser.add_argument("--day-end", default="21:00", type=lambda s: datetime.strptime(s, "%H:%M").time()) + args = parser.parse_args() + + level = logging.INFO if args.verbose else logging.WARN + logging.basicConfig(level=level, format="%(asctime)s %(message)s") + + asyncio.run(main_task(args)) diff --git a/ppboatwatch/ppbw.py b/ppboatwatch/ppbw.py @@ -1,155 +0,0 @@ -import argparse -import asyncio -import json -import logging -import random -import tempfile -import time -import os -import sys - -from datetime import datetime -from slack_sdk.web.async_client import AsyncWebClient -from PIL import Image, ImageDraw, ImageFont - -from .archive import Archive -from .stream_sampler import StreamSampler -from .object_detector import ObjectDetector - - -async def sample_stream(frames_q, sampler, min_delay, max_delay, day_start, day_end): - fail_count, max_failures = 0, 5 - while True: - try: - frames = await sampler.get_recent_frames() - logging.warning(f"[sample_stream]: Extracted {len(frames)} recent frames") - assert len(frames) > 0, "No frames extracted" - await frames_q.put(frames[0].path) - for frame in frames[1:]: # only keep 1 frame - os.remove(frame) - fail_count = 0 # this request succeeded, reset fail counter. - except Exception as ex: - logging.error(f"[sample_stream]: Failed to get_recent_frames: {ex}") - fail_count += 1 - if fail_count >= max_failures: - logging.warning("[sample_stream]: Too many download failures. Killing program!") - sys.exit(1) - - if day_start <= datetime.now().time() <= day_end: - delay = random.randint(min_delay, max_delay) - else: - logging.warning(f"[sample_stream]: Day has ended!") - delay = 86400 - (datetime.combine(datetime.today(), day_end) - datetime.combine(datetime.today(), day_start)).total_seconds() - logging.warning(f"[sample_stream]: Sleeping {delay} seconds") - await asyncio.sleep(delay) - - -async def find_matches(frames_q, archive_q, announce_q): - detector = ObjectDetector(thresh=0.80) - while True: - # Get filenames from the queue and run object detector. - frame_file = await frames_q.get() - image = Image.open(frame_file) - matches = list(filter_matches(detector.find(image))) - if len(matches) == 0: - os.remove(frame_file) - logging.warning(f"[find_matches]: No matches; cleaned up") - continue - # Archive the frame and matches. - await archive_q.put((frame_file, matches)) - # Draw bounding boxes and send to Slack. - draw = ImageDraw.Draw(image) - for label, score, box in matches: - draw.rectangle(box, outline=(255, 255, 255), width=1) - out_file = tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") - image.save(out_file.name) - await announce_q.put(out_file.name) - - -def filter_matches(matches): - for label, score, box in matches: - box = list(box) - # Skip matches at the bottom of the image area (rocks). - if box[1] > 600: - continue - # Skip common but uninteresting cases based on size and label. - box_size = (box[2] - box[0]) * (box[3] - box[1]) - if box_size < 500: - logging.info(f"Skipping small box (box_size={box_size})") - continue - if label == "boat" and box_size < 3000: - logging.info(f"Skipping small boat (box_size={box_size})") - continue - if label == "bird" and box_size < 1000: - logging.info(f"Skipping small bird (box_size={box_size})") - continue - # Return everything else. - yield (label, score, box) - - -async def archive_matches(archive_q, archive): - while True: - file, matches = await archive_q.get() - logging.warning(f"[archive_matches] Archiving {file}: {matches}") - ts = file.split("/")[-1].split("-")[0] - for label, score, box in matches: - archive.add_match(ts=ts, filename=file, label=label, score=score, box=box) - - -async def announce_matches(announce_q, client, max_delay): - last_announce_ts, thread_ts = 0, None - while True: - matches_path = await announce_q.get() - logging.warning(f"[annouce_matches] Posting match {matches_path}") - dt = int(time.time()) - last_announce_ts - thread_ts = await post_match( - client, matches_path, thread_ts if dt < max_delay else None - ) - logging.warning(f"[annnounce_matches] Posted match at thread_ts {thread_ts}") - last_announce_ts = time.time() - - -async def post_match(client, frame_file, thread_ts): - chan_name, chan_id = "#boatwatch", "C06LMBRNV8U" - with open(frame_file, "rb") as f: - res = await client.files_upload(file=f, channels=chan_name, thread_ts=thread_ts) - if not res["ok"]: - logging.error(f"Failed to post_match: {res}") - try: - return res.get("file").get("shares").get("private").get(chan_id)[0].get("ts") - except Exception as ex: - logging.error(f"Failed to post_match: invalid response {ex}") - logging.error(res) - - -async def main_task(args): - archive = Archive(args.db_file) - client = AsyncWebClient(token=os.environ["SLACK_API_TOKEN"]) - sampler = StreamSampler("mavericksov", args.data_dir) - - frames_q = asyncio.Queue() # frames that should be inspected. - archive_q = asyncio.Queue() # matches that should be archived. - announce_q = asyncio.Queue() # matches that should be announced in Slack. - - async with asyncio.TaskGroup() as tg: - tg.create_task(sample_stream(frames_q, sampler, args.min_delay, args.max_delay, args.day_start, args.day_end)) - tg.create_task(find_matches(frames_q, archive_q, announce_q)) - tg.create_task(archive_matches(archive_q, archive)) - tg.create_task(announce_matches(announce_q, client, args.max_delay)) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("-d", "--data-dir", default="./data", type=str) - parser.add_argument("-f", "--db-file", default="./archive.db", type=str) - parser.add_argument("-v", "--verbose", action="store_true") - parser.add_argument("--min-delay", default=10, type=int) - parser.add_argument("--max-delay", default=60, type=int) - parser.add_argument("--day-start", default="6:00", type=lambda s: datetime.strptime(s, "%H:%M").time()) - parser.add_argument("--day-end", default="21:00", type=lambda s: datetime.strptime(s, "%H:%M").time()) - args = parser.parse_args() - - level = logging.INFO if args.verbose else logging.WARN - logging.basicConfig(level=level, format="%(asctime)s %(message)s") - - asyncio.run(main_task(args)) diff --git a/pyproject.toml b/pyproject.toml @@ -56,7 +56,7 @@ dependencies = [ curate-gallery = "ppboatwatch.gallery:curate" find-objects = "ppboatwatch.find_objects:main" gen-gallery = "ppboatwatch.gallery:generate" -ppbw = "ppboatwatch.ppbw:main" +ppbbww = "ppboatwatch.ppbbww:main" sample-streams = "ppboatwatch.sample_streams:main" [tool.setuptools]