wdt123's picture
Add application file
a970154
Raw
History Blame Contribute Delete
17.6 kB
from pathlib import Path
import csv
import uuid
import cv2
import gradio as gr
from sahi import AutoDetectionModel
from sahi.predict import get_sliced_prediction
from ultralytics import YOLO
APP_DIR = Path(__file__).resolve().parent
MODEL_PATH = APP_DIR / "best.onnx"
OUTPUTS_DIR = APP_DIR / "outputs"
TILE_SIZE = 640
OVERLAP_RATIO = 0.25
CONFIDENCE_THRESHOLD = 0.5
NMS_MATCH_THRESHOLD = 0.3
MAX_TRACK_MISSES = 4
MIN_TRACK_FRAMES = 3
MOTION_THRESHOLD_PX = 2.0
MAX_CENTER_DISTANCE_PX = 120.0
MIN_IOU_FOR_MATCH = 0.05
CLASS_COLORS = {
"predator": (255, 99, 71),
"prey": (0, 200, 83),
}
base_model = YOLO(str(MODEL_PATH), task="detect")
detection_model = AutoDetectionModel.from_pretrained(
model_type="ultralytics",
model=base_model,
model_path=str(MODEL_PATH),
confidence_threshold=CONFIDENCE_THRESHOLD,
device="cpu",
load_at_init=True,
)
def intersection_over_smaller(box_a, box_b):
ax1, ay1, ax2, ay2 = box_a
bx1, by1, bx2, by2 = box_b
inter_x1 = max(ax1, bx1)
inter_y1 = max(ay1, by1)
inter_x2 = min(ax2, bx2)
inter_y2 = min(ay2, by2)
inter_w = max(0.0, inter_x2 - inter_x1)
inter_h = max(0.0, inter_y2 - inter_y1)
intersection = inter_w * inter_h
if intersection <= 0:
return 0.0
area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
smaller_area = min(area_a, area_b)
if smaller_area <= 0:
return 0.0
return intersection / smaller_area
def bbox_iou(box_a, box_b):
ax1, ay1, ax2, ay2 = box_a
bx1, by1, bx2, by2 = box_b
inter_x1 = max(ax1, bx1)
inter_y1 = max(ay1, by1)
inter_x2 = min(ax2, bx2)
inter_y2 = min(ay2, by2)
inter_w = max(0.0, inter_x2 - inter_x1)
inter_h = max(0.0, inter_y2 - inter_y1)
intersection = inter_w * inter_h
if intersection <= 0:
return 0.0
area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
union = area_a + area_b - intersection
if union <= 0:
return 0.0
return intersection / union
def normalized_center_distance(box_a, box_b):
ax1, ay1, ax2, ay2 = box_a
bx1, by1, bx2, by2 = box_b
center_a = ((ax1 + ax2) / 2.0, (ay1 + ay2) / 2.0)
center_b = ((bx1 + bx2) / 2.0, (by1 + by2) / 2.0)
center_distance = ((center_a[0] - center_b[0]) ** 2 + (center_a[1] - center_b[1]) ** 2) ** 0.5
width_a = max(0.0, ax2 - ax1)
height_a = max(0.0, ay2 - ay1)
width_b = max(0.0, bx2 - bx1)
height_b = max(0.0, by2 - by1)
smaller_diagonal = min((width_a ** 2 + height_a ** 2) ** 0.5, (width_b ** 2 + height_b ** 2) ** 0.5)
if smaller_diagonal <= 0:
return float("inf")
return center_distance / smaller_diagonal
def resolve_cross_class_duplicates(predictions):
if len(predictions) < 2:
return predictions
duplicate_ios_threshold = 0.85
center_distance_ratio_threshold = 0.35
kept_predictions = []
for pred in sorted(
predictions,
key=lambda item: float(getattr(item.score, "value", 0.0)),
reverse=True,
):
should_skip = False
current_bbox = pred.bbox.to_xyxy()
current_class = pred.category.name
for kept in kept_predictions:
if kept.category.name == current_class:
continue
kept_bbox = kept.bbox.to_xyxy()
ios = intersection_over_smaller(current_bbox, kept_bbox)
center_distance_ratio = normalized_center_distance(current_bbox, kept_bbox)
if ios >= duplicate_ios_threshold and center_distance_ratio <= center_distance_ratio_threshold:
should_skip = True
break
if not should_skip:
kept_predictions.append(pred)
return kept_predictions
def run_sliced_detection(image):
result = get_sliced_prediction(
image,
detection_model,
slice_height=TILE_SIZE,
slice_width=TILE_SIZE,
overlap_height_ratio=OVERLAP_RATIO,
overlap_width_ratio=OVERLAP_RATIO,
postprocess_type="GREEDYNMM",
postprocess_match_metric="IOS",
postprocess_match_threshold=NMS_MATCH_THRESHOLD,
postprocess_class_agnostic=False,
perform_standard_pred=False,
verbose=0,
)
return resolve_cross_class_duplicates(result.object_prediction_list)
def prediction_to_detection(pred):
x1, y1, x2, y2 = [float(v) for v in pred.bbox.to_xyxy()]
center_x = (x1 + x2) / 2.0
center_y = (y1 + y2) / 2.0
return {
"bbox": [x1, y1, x2, y2],
"class_name": pred.category.name,
"score": float(getattr(pred.score, "value", 0.0)),
"center": [center_x, center_y],
}
def render_image(image_rgb, detections):
rendered = image_rgb.copy()
for det in detections:
x1, y1, x2, y2 = [int(v) for v in det["bbox"]]
label = det["class_name"]
score = det["score"]
track_id = det.get("track_id")
color = CLASS_COLORS.get(label, (255, 193, 7))
cv2.rectangle(rendered, (x1, y1), (x2, y2), color, 3)
text = f"{label} {score:.2f}"
if track_id is not None:
text = f"ID {track_id} | {text}"
(text_width, text_height), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2)
text_y = max(y1 - 10, text_height + 8)
cv2.rectangle(
rendered,
(x1, text_y - text_height - 8),
(x1 + text_width + 10, text_y),
color,
-1,
)
cv2.putText(
rendered,
text,
(x1 + 5, text_y - 5),
cv2.FONT_HERSHEY_SIMPLEX,
0.7,
(255, 255, 255),
2,
cv2.LINE_AA,
)
return rendered
def detect_images(files):
if not files:
return []
outputs = []
for file_path in files:
image_bgr = cv2.imread(str(file_path))
if image_bgr is None:
continue
image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
detections = [prediction_to_detection(pred) for pred in run_sliced_detection(image_rgb)]
outputs.append(render_image(image_rgb, detections))
return outputs
def build_track(track_id, detection, frame_idx):
return {
"track_id": track_id,
"class_name": detection["class_name"],
"bbox": detection["bbox"],
"center": detection["center"],
"last_frame_idx": frame_idx,
"frames_seen": 1,
"misses": 0,
"total_distance_px": 0.0,
"moving_frames": 0,
"sum_confidence": detection["score"],
}
def assign_tracks(detections, tracks, next_track_id, frame_idx):
matched_detections = set()
matched_tracks = set()
candidate_pairs = []
for det_idx, detection in enumerate(detections):
for track_id, track in tracks.items():
if track["class_name"] != detection["class_name"]:
continue
if track["misses"] > MAX_TRACK_MISSES:
continue
prev_center = track["center"]
curr_center = detection["center"]
center_distance = ((prev_center[0] - curr_center[0]) ** 2 + (prev_center[1] - curr_center[1]) ** 2) ** 0.5
iou = bbox_iou(track["bbox"], detection["bbox"])
dynamic_threshold = max(
MAX_CENTER_DISTANCE_PX,
0.75 * min(
((track["bbox"][2] - track["bbox"][0]) ** 2 + (track["bbox"][3] - track["bbox"][1]) ** 2) ** 0.5,
((detection["bbox"][2] - detection["bbox"][0]) ** 2 + (detection["bbox"][3] - detection["bbox"][1]) ** 2) ** 0.5,
),
)
if center_distance <= dynamic_threshold or iou >= MIN_IOU_FOR_MATCH:
candidate_pairs.append((-iou, center_distance, track_id, det_idx))
for _, center_distance, track_id, det_idx in sorted(candidate_pairs):
if track_id in matched_tracks or det_idx in matched_detections:
continue
detection = detections[det_idx]
track = tracks[track_id]
track["total_distance_px"] += center_distance
track["moving_frames"] += int(center_distance >= MOTION_THRESHOLD_PX)
track["bbox"] = detection["bbox"]
track["center"] = detection["center"]
track["last_frame_idx"] = frame_idx
track["frames_seen"] += 1
track["misses"] = 0
track["sum_confidence"] += detection["score"]
detection["track_id"] = track_id
matched_tracks.add(track_id)
matched_detections.add(det_idx)
for track_id, track in tracks.items():
if track_id not in matched_tracks:
track["misses"] += 1
for det_idx, detection in enumerate(detections):
if det_idx in matched_detections:
continue
track_id = next_track_id
next_track_id += 1
detection["track_id"] = track_id
tracks[track_id] = build_track(track_id, detection, frame_idx)
return next_track_id
def write_tracks_csv(path, rows):
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(
handle,
fieldnames=[
"frame_idx",
"track_id",
"class_name",
"confidence",
"center_x",
"center_y",
"x1",
"y1",
"x2",
"y2",
],
)
writer.writeheader()
writer.writerows(rows)
def summarize_tracks(tracks, frame_count, fps):
confirmed_tracks = [track for track in tracks.values() if track["frames_seen"] >= MIN_TRACK_FRAMES]
observed_seconds = frame_count / fps if fps else 0.0
summary_rows = []
for track in confirmed_tracks:
duration_seconds = track["frames_seen"] / fps if fps else 0.0
avg_speed = track["total_distance_px"] / duration_seconds if duration_seconds else 0.0
movement_ratio = track["moving_frames"] / max(track["frames_seen"] - 1, 1)
visibility_ratio = track["frames_seen"] / max(frame_count, 1)
summary_rows.append(
{
"track_id": track["track_id"],
"class_name": track["class_name"],
"frames_seen": track["frames_seen"],
"total_distance_px": round(track["total_distance_px"], 3),
"avg_speed_px_per_sec": round(avg_speed, 3),
"movement_ratio": round(movement_ratio, 4),
"visibility_ratio": round(visibility_ratio, 4),
"avg_confidence": round(track["sum_confidence"] / track["frames_seen"], 4),
}
)
moving_tracks = [
row for row in summary_rows
if row["total_distance_px"] >= MOTION_THRESHOLD_PX * 2
]
avg_speed_px_per_sec = (
sum(row["avg_speed_px_per_sec"] for row in summary_rows) / len(summary_rows)
if summary_rows else 0.0
)
moving_object_ratio = len(moving_tracks) / len(summary_rows) if summary_rows else 0.0
observation_stability = (
sum(row["visibility_ratio"] for row in summary_rows) / len(summary_rows)
if summary_rows else 0.0
)
vitality_score = avg_speed_px_per_sec * moving_object_ratio * observation_stability
aggregate = {
"frame_count": frame_count,
"fps": round(fps, 3),
"observed_seconds": round(observed_seconds, 3),
"confirmed_tracks": len(summary_rows),
"moving_tracks": len(moving_tracks),
"avg_speed_px_per_sec": round(avg_speed_px_per_sec, 3),
"moving_object_ratio": round(moving_object_ratio, 4),
"observation_stability": round(observation_stability, 4),
"vitality_score": round(vitality_score, 3),
}
return summary_rows, aggregate
def write_summary_csv(path, summary_rows, aggregate):
with path.open("w", newline="", encoding="utf-8") as handle:
aggregate_writer = csv.writer(handle)
aggregate_writer.writerow(["metric", "value"])
for key, value in aggregate.items():
aggregate_writer.writerow([key, value])
aggregate_writer.writerow([])
aggregate_writer.writerow(
[
"track_id",
"class_name",
"frames_seen",
"total_distance_px",
"avg_speed_px_per_sec",
"movement_ratio",
"visibility_ratio",
"avg_confidence",
]
)
for row in summary_rows:
aggregate_writer.writerow(
[
row["track_id"],
row["class_name"],
row["frames_seen"],
row["total_distance_px"],
row["avg_speed_px_per_sec"],
row["movement_ratio"],
row["visibility_ratio"],
row["avg_confidence"],
]
)
def build_summary_markdown(aggregate, summary_rows):
lines = [
"### Vitality Summary",
f"- Frames processed: {aggregate['frame_count']}",
f"- Observed seconds: {aggregate['observed_seconds']}",
f"- Confirmed tracks: {aggregate['confirmed_tracks']}",
f"- Moving tracks: {aggregate['moving_tracks']}",
f"- Average speed: {aggregate['avg_speed_px_per_sec']} px/s",
f"- Moving object ratio: {aggregate['moving_object_ratio']}",
f"- Observation stability: {aggregate['observation_stability']}",
f"- Vitality score: {aggregate['vitality_score']}",
]
if summary_rows:
top_tracks = sorted(summary_rows, key=lambda row: row["total_distance_px"], reverse=True)[:5]
lines.append("")
lines.append("Top tracks by distance:")
for row in top_tracks:
lines.append(
f"- ID {row['track_id']} ({row['class_name']}): "
f"{row['total_distance_px']} px over {row['frames_seen']} frames"
)
return "\n".join(lines)
def track_video(video_path):
if not video_path:
return None, [], "No video provided."
run_dir = OUTPUTS_DIR / f"video_{uuid.uuid4().hex[:8]}"
run_dir.mkdir(parents=True, exist_ok=True)
output_video = run_dir / "tracked.mp4"
tracks_csv = run_dir / "tracks.csv"
summary_csv = run_dir / "summary.csv"
capture = cv2.VideoCapture(str(video_path))
if not capture.isOpened():
return None, [], "Failed to open the uploaded video."
fps = capture.get(cv2.CAP_PROP_FPS) or 10.0
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
writer = cv2.VideoWriter(
str(output_video),
cv2.VideoWriter_fourcc(*"mp4v"),
fps,
(width, height),
)
tracks = {}
track_rows = []
next_track_id = 1
frame_idx = 0
while True:
ok, frame_bgr = capture.read()
if not ok:
break
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
predictions = run_sliced_detection(frame_rgb)
detections = [prediction_to_detection(pred) for pred in predictions]
next_track_id = assign_tracks(detections, tracks, next_track_id, frame_idx)
for detection in detections:
x1, y1, x2, y2 = detection["bbox"]
center_x, center_y = detection["center"]
track_rows.append(
{
"frame_idx": frame_idx,
"track_id": detection["track_id"],
"class_name": detection["class_name"],
"confidence": round(detection["score"], 4),
"center_x": round(center_x, 3),
"center_y": round(center_y, 3),
"x1": round(x1, 3),
"y1": round(y1, 3),
"x2": round(x2, 3),
"y2": round(y2, 3),
}
)
rendered_rgb = render_image(frame_rgb, detections)
rendered_bgr = cv2.cvtColor(rendered_rgb, cv2.COLOR_RGB2BGR)
writer.write(rendered_bgr)
frame_idx += 1
capture.release()
writer.release()
write_tracks_csv(tracks_csv, track_rows)
summary_rows, aggregate = summarize_tracks(tracks, frame_idx, fps)
write_summary_csv(summary_csv, summary_rows, aggregate)
summary_markdown = build_summary_markdown(aggregate, summary_rows)
return str(output_video), [str(tracks_csv), str(summary_csv)], summary_markdown
with gr.Blocks() as demo:
gr.Markdown("# Mite Detection, Tracking, and Vitality")
with gr.Tab("Image Detection"):
image_input = gr.Files(label="Upload images", file_count="multiple", type="filepath")
image_output = gr.Gallery(label="Tiled detections", columns=2, preview=True)
image_run = gr.Button("Run Detection", variant="primary")
image_run.click(fn=detect_images, inputs=image_input, outputs=image_output)
with gr.Tab("Video Tracking"):
video_input = gr.Video(label="Upload a video")
video_output = gr.Video(label="Tracked output")
csv_output = gr.Files(label="Tracking CSV outputs")
summary_output = gr.Markdown(label="Vitality summary")
video_run = gr.Button("Run Tracking", variant="primary")
video_run.click(
fn=track_video,
inputs=video_input,
outputs=[video_output, csv_output, summary_output],
)
if __name__ == "__main__":
demo.launch()