shell-size-app / app.py
HarryEslick's picture
update tile size
0a7c131 verified
"""
Gradio web application for detecting and measuring objects in images.
Key features:
- Image scaling tool to set measurement reference
- Object detection using YOLOv8 model for scallop/spat detection
- Interactive annotation of detected objects
- Size measurements in mm based on scale reference
- Statistics and histogram visualization of object sizes
- Export results to CSV
"""
# %% #|> Imports |
from pathlib import Path
import cv2
import gradio as gr
from gradio_image_annotation import image_annotator
import numpy as np
import pandas as pd
import supervision as sv
import logging
import os
import plotly.express as px
from spatstatapp.inference import inference_large
from spatstatapp.plotting import coco_to_detections
from spatstatapp.tile_training_data import load_bboxes
import gradio as gr
import numpy as np
from PIL import Image, ImageDraw
import cv2
import logging
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# %% load image and detections |
def get_asset_path(relative_path):
if os.getenv('SPACE_ID'):
# Running on HF Spaces
return Path.cwd() / relative_path
# Running locally
return Path(relative_path)
model_path = get_asset_path(Path("models/best.onnx"))
data_dir=get_asset_path(Path("img"))
data_dir.exists()
example_imgs = list(data_dir.glob("*.jpg"))
class PointSelector:
def __init__(self, image=None):
self.points = []
# self.og_img = image
self.image_path = image
self.line_len_px = None
self.line_len_mm = None
# def reset(self):
# self.points = []
# return self.og_img, "Points cleared"
def clear_og_img(self, _x_):
# self.og_img = image.copy()
# raise Exception(image)
# self.og_img = None
self.points = []
self.image_path = None
def reset_og_img(self, image):
# self.og_img = image.copy()
# raise Exception(image)
# self.og_img = None
self.points = []
self.image_path = image
def add_point(self, image, evt: gr.SelectData):
img_draw = cv2.imread(image)#[:,:,::-1]
if self.image_path is None:
self.image_path = image
if (len(self.points) == 0):# & (self.og_img is None):
# self.image_path = image
img_draw = cv2.imread(self.image_path)
# self.og_img = cv2.imread(image)
# img_draw = self.og_img.copy()
if len(self.points) >= 2:
self.points = []
img_draw = cv2.imread(self.image_path)
# img_draw = self.og_img.copy()
self.points.append((evt.index[0], evt.index[1]))
# Draw on image
# img_draw = image.copy()
if len(self.points) > 0:
for pt in self.points:
cv2.circle(img_draw, (int(pt[0]), int(pt[1])), 5, (255,0,0), -1)
if len(self.points) == 2:
cv2.line(img_draw,
(int(self.points[0][0]), int(self.points[0][1])),
(int(self.points[1][0]), int(self.points[1][1])),
(0,255,0), 3)
# Calculate distance
dist = np.sqrt((self.points[1][0] - self.points[0][0])**2 +
(self.points[1][1] - self.points[0][1])**2)
msg = f"Distance: {dist:.1f} pixels"
self.line_len_px = dist
# self.image_path = None
self.points = []
else:
msg = f"Click point {len(self.points)+1}"
return img_draw[:,:,::-1], msg
def set_line_length(self, line_len_mm, button):
self.line_len_mm = line_len_mm
return self.check_scale_set(button)
def check_scale_set(self, button):
if (self.line_len_mm is not None) & (self.line_len_px is not None):
# if True:
return gr.update(visible=True)
else:
return gr.update(visible=False)
def save_scaled_boxes(self, annotator):
try:
json_data = annotator["boxes"]
if len(json_data)==0:
return None
else:
df = pd.DataFrame(json_data).drop(columns=["color"], errors='ignore')
df["xrange"] = ((df["xmax"] - df["xmin"])*(self.line_len_mm/self.line_len_px)).round(2)
df["yrange"] = ((df["ymax"] - df["ymin"])*(self.line_len_mm/self.line_len_px)).round(2)
df["mean_daimeter_mm"] = ((df["yrange"]+df["xrange"])/2).round(2)
return df
except Exception as e:
return None
def detections_to_json(detections:sv.Detections, image:np.ndarray):
"""Add predictions to canvas"""
boxes = []
for xyxy, mask, confidence, class_id, tracker_id, data in detections:
xmin, ymin, xmax, ymax = xyxy
obj = {
"xmin": float(xmin),
"ymin": float(ymin),
"xmax": float(xmax),
"ymax": float(ymax),
"label": "",# data["class_name"],
"color": (255, 0, 0)
}
boxes.append(obj)
annotation = {
"image": image,
"boxes": boxes
}
return annotation
def create_histogram(df):
# print(type(df))
# print(len(df))
print()
if df is None or len(df) == 0 or df.iloc[0,1]=="":
return None
fig = px.histogram(df, x="mean_daimeter_mm",
title="Distribution of Shell Sizes",
labels={"mean_daimeter_mm": "Mean Diameter (mm)"},
nbins=30)
return fig
# def get_boxes_table(annotator):
# json_data = annotator["boxes"]
# if len(json_data)==0:
# return pd.DataFrame()
# else:
# df = pd.DataFrame(json_data).drop(columns=["color"], errors='ignore')
# return df
from ultralytics.utils.ops import xywhn2xyxy
def find_boxes_json(image_path):
# print(annotator)
img = cv2.imread(image_path)
detections = inference_large(img, model_path, sam_path=None, edge_pct=0.01, conf_threshold=0.4, overlap_px=200, tile_px=640)
annotations = detections_to_json(detections, image_path)
annotations["image"] = image_path
# annotator.update(annotations)
annotator = image_annotator(
annotations,
boxes_alpha=0.02,
handle_size=4,
show_label=False,
)
return annotator, annotations["boxes"]
def load_coco_boxes(image_path, coco_file, class_labels="scallop"):
image = cv2.imread(image_path)[:,:,::-1]
detections = coco_to_detections(coco_file, image)
annotations = detections_to_json(detections, image)
annotations["image"] = image_path
# annotator.update(annotations)
annotator = image_annotator(
annotations,
boxes_alpha=0.02,
handle_size=4,
show_label=False,
)
return annotator, annotations["boxes"]
selector = PointSelector()
with gr.Blocks(
theme=gr.themes.Default()
) as demo:
with gr.Tabs() as tabs:
# %% #|> Tab0 |
with gr.TabItem("0. readme", id=3, visible=True):
gr.Markdown(
"""
# Spatstatapp
Spatstatapp is a tool for detecting and measuring objects in images.
## Features
- Image scaling tool to set measurement reference
- Object detection using YOLOv11 model for scallop/spat detection
- Interactive annotation of detected objects
- Size measurements in mm based on scale reference
- Statistics and histogram visualization of object sizes
- Export results to CSV
## Note
- This is a demo application, accuracy and speed are not optimized.
- The model is trained on a 5 images only
- accuracy will be low when input data very different from training data.
- accuracy can be improved with additional data.
- Speed is slow due to the large image size and free hosting.
## Usage
1. Upload an image
2. Click two points to create a scale bar.
3. Set the scale bar length in mm.
4. Click "find bounding boxes" to detect objects in the image.
5. Adjust the detected objects by dragging the handles, deleting or create new boxes.
6. Export boxes and statistics to CSV.
"""
)
# %% Tab 1 |
with gr.TabItem("1. Image Scale", id=0):
with gr.Row():
with gr.Column(scale=10):
image_input = gr.Image(label="Click two points to measure distance",
type="filepath",
interactive=True
)
# default_image = gr.Dropdown(
# choices=["None"] + list(default_images.keys()),
# label="Use default image?",
# )
exmples = gr.Examples(example_imgs, image_input,
cache_examples=False,
run_on_click= True,
fn=selector.clear_og_img,
)
with gr.Column(scale=1, min_width=200):
filename = gr.Textbox(label="Filename")
output_text = gr.Textbox(label="Status", value="Click two points to measure distance")
line_length_mm = gr.Number(label="line length in mm")
# target_select = gr.Radio(label ="select target:", visible=True, choices=["scallop", "spat"])
button_find = gr.Button("find bounding boxes", visible=False)
load_annot_btn = gr.Button("Load Existing boxes (Optional)", visible=False)
load_annot = gr.File(label="Load Existing boxes (Optional)",file_types=[".txt"], file_count="single", visible=False, height=500)
# test_text = gr.Textbox(label="test")
# %% #|> T1: event handlers |
# image_input.upload()
# default_image.change(
# lambda x: default_images[x] if x in default_images.keys() else None,
# inputs=[default_image],
# outputs=[image_input]
# )
image_input.upload(
selector.reset_og_img,
inputs=[image_input],
)
image_input.upload(
lambda x: Path(x).name,
inputs=[image_input],
outputs=[filename]
)
# Event handlers
image_input.select(
selector.add_point,
inputs=[image_input],
outputs=[image_input, output_text]
)
line_length_mm.change(
selector.set_line_length,
inputs=[line_length_mm, button_find],
outputs=[button_find]
)
line_length_mm.change(
selector.check_scale_set,
inputs = load_annot_btn,
outputs=load_annot_btn,
)
load_annot_btn.click(
lambda: gr.update(visible=True),
outputs=[load_annot]
)
# load_annot.upload(
# load_bboxes,
# inputs=[load_annot],
# outputs=[test_text]
# )
# %% #|> Tab2 |
with gr.TabItem("2. Object annotation", id=1, visible=True):
annotator = image_annotator(
boxes_alpha=0.02,
handle_size=4,
show_label=False,
label_list=["scallop", "spat"],
label_colors=[(255, 0, 0), (255, 200, 0)]
)
# button_get = gr.Button("Get bounding boxes")
download_file = gr.File(
label="Download CSV",
visible=True,
# interactive=True
)
with gr.Row():
with gr.Column(scale=1):
obj_count = gr.Textbox(label="Object count")
# button_save = gr.Button("save bounding boxes")
with gr.Column(scale=1):
obj_size = gr.Textbox(value = "Has the scale size been set?" ,label="Mean size")
histogram = gr.Plot()
table = gr.DataFrame(
max_height=500,
)
# table = gr.Textbox(label="Status", value=1)
json_data = gr.JSON(value={}, visible=False)
# %% #|> T2: event handlers |
json_boxes = button_find.click(
fn=find_boxes_json,
inputs=[image_input],
outputs=[annotator, json_data]
)
button_find.click(
fn=lambda: gr.Tabs(selected=1),
outputs=tabs
)
json_boxes = load_annot.upload(
fn=load_coco_boxes,
inputs=[image_input, load_annot],
outputs=[annotator, json_data]
)
# button_find.click(
# fn=change_tab,
# # inputs=[annotator, image_input],
# outputs=tabs
# )
# annotator.change(
# json_boxes = button_get.click(
json_boxes = annotator.change(
fn=selector.save_scaled_boxes,
inputs= [annotator],
outputs= table
)
table.change(
fn=create_histogram,
inputs=[table],
outputs=[histogram]
)
def df_mean_count(df):
try:
mean = df["mean_daimeter_mm"].mean().round(2)
count = len(df)
return mean, count
except Exception as e:
return "Has the scale size been set?", None
table.change(
fn=df_mean_count,
inputs=[table],
outputs=[obj_size, obj_count]
)
def save_and_download_table(df, img_name):
try:
# Create temporary file with .csv extension
# with NamedTemporaryFile(delete=False, suffix='.csv') as tmp_file:
# csv_path = tmp_file.name
csv_path = Path(img_name).stem +"_boxes.csv"
df.to_csv(csv_path, index=False)
return csv_path
except Exception as e:
return None
table.change(
fn=save_and_download_table,
inputs=[table, filename],
outputs=[download_file]
)
if __name__ == "__main__":
demo.launch(
# server_name="0.0.0.0",
# server_port=7860,
show_error=True,
debug=True,
)