Add advanced annotation style options and automatic dark mode UI support (update 17)
Browse files- CHANGELOG.md +6 -0
- README.md +3 -0
- app.py +59 -16
CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
| 1 |
# Changelog
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
## Update ke-16 - 2026-04-15
|
| 4 |
- Perbaikan error Gradio `IsADirectoryError: '/app'` pada output file training.
|
| 5 |
- Output komponen `gr.File` di mode streaming kini mengirim `None` (bukan string kosong) saat file belum siap.
|
|
|
|
| 1 |
# Changelog
|
| 2 |
|
| 3 |
+
## Update ke-17 - 2026-04-15
|
| 4 |
+
- Tambah `Advanced Options` pada inferensi: pilih gaya anotasi `Border + Label` atau `Label saja`.
|
| 5 |
+
- Opsi anotasi berlaku untuk semua model (YOLOv8n, Best.pt, dan mode perbandingan).
|
| 6 |
+
- Tambah adaptasi UI dark mode otomatis via `prefers-color-scheme: dark`.
|
| 7 |
+
- Minor update dinaikkan ke-17.
|
| 8 |
+
|
| 9 |
## Update ke-16 - 2026-04-15
|
| 10 |
- Perbaikan error Gradio `IsADirectoryError: '/app'` pada output file training.
|
| 11 |
- Output komponen `gr.File` di mode streaming kini mengirim `None` (bukan string kosong) saat file belum siap.
|
README.md
CHANGED
|
@@ -36,6 +36,8 @@ Versi ini difokuskan untuk tahap awal:
|
|
| 36 |
- **Section 1 - Input & Proses**
|
| 37 |
- input gambar (upload/webcam)
|
| 38 |
- confidence threshold
|
|
|
|
|
|
|
| 39 |
- tombol proses
|
| 40 |
- **Section 2 - Hasil Processing**
|
| 41 |
- hasil gambar deteksi
|
|
@@ -53,6 +55,7 @@ Catatan:
|
|
| 53 |
- input evaluasi batch dihapus.
|
| 54 |
- training tersedia sebagai menu terpisah (accordion) di halaman yang sama.
|
| 55 |
- input jumlah presensi belum digunakan pada versi ini.
|
|
|
|
| 56 |
|
| 57 |
## File Penting
|
| 58 |
|
|
|
|
| 36 |
- **Section 1 - Input & Proses**
|
| 37 |
- input gambar (upload/webcam)
|
| 38 |
- confidence threshold
|
| 39 |
+
- advanced options:
|
| 40 |
+
- gaya anotasi `Border + Label` atau `Label saja`
|
| 41 |
- tombol proses
|
| 42 |
- **Section 2 - Hasil Processing**
|
| 43 |
- hasil gambar deteksi
|
|
|
|
| 55 |
- input evaluasi batch dihapus.
|
| 56 |
- training tersedia sebagai menu terpisah (accordion) di halaman yang sama.
|
| 57 |
- input jumlah presensi belum digunakan pada versi ini.
|
| 58 |
+
- UI otomatis menyesuaikan mode gelap perangkat (dark mode).
|
| 59 |
|
| 60 |
## File Penting
|
| 61 |
|
app.py
CHANGED
|
@@ -24,7 +24,7 @@ from ultralytics import YOLO
|
|
| 24 |
CM_INIT = {"tp": 0, "fp": 0, "fn": 0, "tn": 0}
|
| 25 |
EVAL_CLASSES = ["Sesuai", "Tidak Sesuai"]
|
| 26 |
APP_VERSION = os.getenv("APP_VERSION", "v0.9.0")
|
| 27 |
-
APP_MINOR_UPDATE = os.getenv("APP_MINOR_UPDATE", "
|
| 28 |
|
| 29 |
|
| 30 |
# Gradio/Spaces can trigger noisy asyncio cleanup traces ("Invalid file descriptor: -1")
|
|
@@ -108,7 +108,12 @@ def _resolve_person_class_id_from_names(names: Any) -> int:
|
|
| 108 |
return 0
|
| 109 |
|
| 110 |
|
| 111 |
-
def _annotate_person_instances(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
annotated = image_rgb.copy()
|
| 113 |
if result.boxes is None:
|
| 114 |
return annotated, 0
|
|
@@ -128,17 +133,29 @@ def _annotate_person_instances(image_rgb: np.ndarray, result: Any, person_id: in
|
|
| 128 |
person_boxes.sort(key=lambda b: (b[1], b[0]))
|
| 129 |
|
| 130 |
for idx, (x1, y1, x2, y2) in enumerate(person_boxes, start=1):
|
| 131 |
-
|
|
|
|
| 132 |
label = f"person {idx}"
|
| 133 |
(tw, th), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
cv2.putText(
|
| 139 |
annotated,
|
| 140 |
label,
|
| 141 |
-
|
| 142 |
cv2.FONT_HERSHEY_SIMPLEX,
|
| 143 |
0.6,
|
| 144 |
(0, 0, 0),
|
|
@@ -148,7 +165,12 @@ def _annotate_person_instances(image_rgb: np.ndarray, result: Any, person_id: in
|
|
| 148 |
return annotated, len(person_boxes)
|
| 149 |
|
| 150 |
|
| 151 |
-
def infer_image(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
if image is None:
|
| 153 |
raise ValueError("Silakan unggah gambar atau gunakan kamera.")
|
| 154 |
model = load_model(model_choice)
|
|
@@ -161,7 +183,7 @@ def infer_image(image: np.ndarray, conf_threshold: float, model_choice: str) ->
|
|
| 161 |
verbose=False,
|
| 162 |
)[0]
|
| 163 |
person_id = _resolve_person_class_id(result)
|
| 164 |
-
annotated, detected_count = _annotate_person_instances(image, result, person_id)
|
| 165 |
return annotated, detected_count
|
| 166 |
|
| 167 |
|
|
@@ -242,12 +264,13 @@ def run_check_and_classify(
|
|
| 242 |
attendance_count: float,
|
| 243 |
model_choice: str,
|
| 244 |
inference_mode: str,
|
|
|
|
| 245 |
) -> Tuple[np.ndarray, str, np.ndarray, str, str]:
|
| 246 |
try:
|
| 247 |
attendance = int(attendance_count)
|
| 248 |
if inference_mode == "Bandingkan (YOLOv8n vs Best.pt)":
|
| 249 |
-
ann_before, det_before = infer_image(image, conf_threshold, "YOLOv8n")
|
| 250 |
-
ann_after, det_after = infer_image(image, conf_threshold, "Best.pt")
|
| 251 |
match_before = classify_match(det_before, attendance)
|
| 252 |
match_after = classify_match(det_after, attendance)
|
| 253 |
presence_before = classify_presence(det_before)
|
|
@@ -264,7 +287,7 @@ def run_check_and_classify(
|
|
| 264 |
pred_md = f"Label prediksi validasi (Best.pt): **{match_after}**"
|
| 265 |
return combined, summary, chart, match_after, pred_md
|
| 266 |
|
| 267 |
-
annotated, detected = infer_image(image, conf_threshold, model_choice)
|
| 268 |
status_presence = classify_presence(detected)
|
| 269 |
status_match = classify_match(detected, attendance)
|
| 270 |
summary = (
|
|
@@ -274,7 +297,8 @@ def run_check_and_classify(
|
|
| 274 |
f"- Klasifikasi keberadaan: **{status_presence}**\n"
|
| 275 |
f"- Validasi presensi: **{status_match}**\n"
|
| 276 |
f"- Model digunakan: **{model_choice}**\n"
|
| 277 |
-
"-
|
|
|
|
| 278 |
)
|
| 279 |
chart = draw_detection_chart(detected)
|
| 280 |
pred_md = f"Label prediksi validasi: **{status_match}**"
|
|
@@ -997,11 +1021,23 @@ CUSTOM_CSS = """
|
|
| 997 |
.section-box {padding: 14px; border: 1px solid #d7dee8; border-radius: 10px; background: #ffffff;}
|
| 998 |
.method-box {padding: 14px; border: 1px solid #c7d2e3; border-radius: 10px; background: #f8fbff;}
|
| 999 |
.section-title {margin-bottom: 4px !important;}
|
|
|
|
| 1000 |
.version-chip {
|
| 1001 |
display:inline-block; padding:4px 10px; border:1px solid #c7d2e3; border-radius:999px;
|
| 1002 |
font-size:12px; color:#334155; background:#f8fbff; margin-top:4px;
|
| 1003 |
}
|
| 1004 |
.version-foot {font-size:12px; color:#64748b; text-align:center; margin-top:8px;}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1005 |
"""
|
| 1006 |
|
| 1007 |
|
|
@@ -1009,7 +1045,7 @@ with gr.Blocks(title="Pengecekan dan Klasifikasi Mahasiswa - YOLOv8") as demo:
|
|
| 1009 |
gr.HTML(
|
| 1010 |
"""
|
| 1011 |
<h1 style="text-align:center;margin-bottom:4px;">Pengecekan dan Klasifikasi Kehadiran Berbasis YOLOv8</h1>
|
| 1012 |
-
<p
|
| 1013 |
Implementasi klasifikasi kehadiran mahasiswa dalam ruang kelas berbasis citra menggunakan YOLOv8.
|
| 1014 |
</p>
|
| 1015 |
"""
|
|
@@ -1057,6 +1093,13 @@ with gr.Blocks(title="Pengecekan dan Klasifikasi Mahasiswa - YOLOv8") as demo:
|
|
| 1057 |
minimum=0,
|
| 1058 |
)
|
| 1059 |
conf_input = gr.Slider(0.1, 1.0, value=0.25, step=0.05, label="Parameter Confidence (Deteksi)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1060 |
run_btn = gr.Button("Jalankan Deteksi dan Klasifikasi", variant="primary")
|
| 1061 |
|
| 1062 |
with gr.Column(elem_classes=["section-box"]):
|
|
@@ -1119,7 +1162,7 @@ with gr.Blocks(title="Pengecekan dan Klasifikasi Mahasiswa - YOLOv8") as demo:
|
|
| 1119 |
|
| 1120 |
run_btn_evt = run_btn.click(
|
| 1121 |
fn=run_check_and_classify,
|
| 1122 |
-
inputs=[image_input, conf_input, attendance_input, model_choice, inference_mode],
|
| 1123 |
outputs=[output_image, class_result_md, comparison_chart, predicted_state, predicted_label_md],
|
| 1124 |
)
|
| 1125 |
run_btn_evt.then(
|
|
|
|
| 24 |
CM_INIT = {"tp": 0, "fp": 0, "fn": 0, "tn": 0}
|
| 25 |
EVAL_CLASSES = ["Sesuai", "Tidak Sesuai"]
|
| 26 |
APP_VERSION = os.getenv("APP_VERSION", "v0.9.0")
|
| 27 |
+
APP_MINOR_UPDATE = os.getenv("APP_MINOR_UPDATE", "17")
|
| 28 |
|
| 29 |
|
| 30 |
# Gradio/Spaces can trigger noisy asyncio cleanup traces ("Invalid file descriptor: -1")
|
|
|
|
| 108 |
return 0
|
| 109 |
|
| 110 |
|
| 111 |
+
def _annotate_person_instances(
|
| 112 |
+
image_rgb: np.ndarray,
|
| 113 |
+
result: Any,
|
| 114 |
+
person_id: int,
|
| 115 |
+
annotation_style: str,
|
| 116 |
+
) -> Tuple[np.ndarray, int]:
|
| 117 |
annotated = image_rgb.copy()
|
| 118 |
if result.boxes is None:
|
| 119 |
return annotated, 0
|
|
|
|
| 133 |
person_boxes.sort(key=lambda b: (b[1], b[0]))
|
| 134 |
|
| 135 |
for idx, (x1, y1, x2, y2) in enumerate(person_boxes, start=1):
|
| 136 |
+
if annotation_style == "Border + Label":
|
| 137 |
+
cv2.rectangle(annotated, (x1, y1), (x2, y2), (34, 197, 94), 2)
|
| 138 |
label = f"person {idx}"
|
| 139 |
(tw, th), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
|
| 140 |
+
if annotation_style == "Label saja":
|
| 141 |
+
label_x = max(0, min(x1, annotated.shape[1] - tw - 8))
|
| 142 |
+
label_y = max(th + 4, min(y1 + th + 4, annotated.shape[0] - 6))
|
| 143 |
+
text_y1 = max(0, label_y - th - baseline - 6)
|
| 144 |
+
text_y2 = min(annotated.shape[0], label_y + 2)
|
| 145 |
+
text_x1 = label_x
|
| 146 |
+
text_x2 = min(annotated.shape[1], label_x + tw + 10)
|
| 147 |
+
text_org = (label_x + 4, label_y - 4)
|
| 148 |
+
else:
|
| 149 |
+
text_y1 = max(0, y1 - th - baseline - 6)
|
| 150 |
+
text_y2 = y1
|
| 151 |
+
text_x1 = x1
|
| 152 |
+
text_x2 = min(annotated.shape[1], x1 + tw + 10)
|
| 153 |
+
text_org = (x1 + 4, max(14, y1 - 6))
|
| 154 |
+
cv2.rectangle(annotated, (text_x1, text_y1), (text_x2, text_y2), (34, 197, 94), -1)
|
| 155 |
cv2.putText(
|
| 156 |
annotated,
|
| 157 |
label,
|
| 158 |
+
text_org,
|
| 159 |
cv2.FONT_HERSHEY_SIMPLEX,
|
| 160 |
0.6,
|
| 161 |
(0, 0, 0),
|
|
|
|
| 165 |
return annotated, len(person_boxes)
|
| 166 |
|
| 167 |
|
| 168 |
+
def infer_image(
|
| 169 |
+
image: np.ndarray,
|
| 170 |
+
conf_threshold: float,
|
| 171 |
+
model_choice: str,
|
| 172 |
+
annotation_style: str,
|
| 173 |
+
) -> Tuple[np.ndarray, int]:
|
| 174 |
if image is None:
|
| 175 |
raise ValueError("Silakan unggah gambar atau gunakan kamera.")
|
| 176 |
model = load_model(model_choice)
|
|
|
|
| 183 |
verbose=False,
|
| 184 |
)[0]
|
| 185 |
person_id = _resolve_person_class_id(result)
|
| 186 |
+
annotated, detected_count = _annotate_person_instances(image, result, person_id, annotation_style)
|
| 187 |
return annotated, detected_count
|
| 188 |
|
| 189 |
|
|
|
|
| 264 |
attendance_count: float,
|
| 265 |
model_choice: str,
|
| 266 |
inference_mode: str,
|
| 267 |
+
annotation_style: str,
|
| 268 |
) -> Tuple[np.ndarray, str, np.ndarray, str, str]:
|
| 269 |
try:
|
| 270 |
attendance = int(attendance_count)
|
| 271 |
if inference_mode == "Bandingkan (YOLOv8n vs Best.pt)":
|
| 272 |
+
ann_before, det_before = infer_image(image, conf_threshold, "YOLOv8n", annotation_style)
|
| 273 |
+
ann_after, det_after = infer_image(image, conf_threshold, "Best.pt", annotation_style)
|
| 274 |
match_before = classify_match(det_before, attendance)
|
| 275 |
match_after = classify_match(det_after, attendance)
|
| 276 |
presence_before = classify_presence(det_before)
|
|
|
|
| 287 |
pred_md = f"Label prediksi validasi (Best.pt): **{match_after}**"
|
| 288 |
return combined, summary, chart, match_after, pred_md
|
| 289 |
|
| 290 |
+
annotated, detected = infer_image(image, conf_threshold, model_choice, annotation_style)
|
| 291 |
status_presence = classify_presence(detected)
|
| 292 |
status_match = classify_match(detected, attendance)
|
| 293 |
summary = (
|
|
|
|
| 297 |
f"- Klasifikasi keberadaan: **{status_presence}**\n"
|
| 298 |
f"- Validasi presensi: **{status_match}**\n"
|
| 299 |
f"- Model digunakan: **{model_choice}**\n"
|
| 300 |
+
f"- Gaya anotasi: **{annotation_style}**\n"
|
| 301 |
+
"- Label objek: **person 1, person 2, ...**"
|
| 302 |
)
|
| 303 |
chart = draw_detection_chart(detected)
|
| 304 |
pred_md = f"Label prediksi validasi: **{status_match}**"
|
|
|
|
| 1021 |
.section-box {padding: 14px; border: 1px solid #d7dee8; border-radius: 10px; background: #ffffff;}
|
| 1022 |
.method-box {padding: 14px; border: 1px solid #c7d2e3; border-radius: 10px; background: #f8fbff;}
|
| 1023 |
.section-title {margin-bottom: 4px !important;}
|
| 1024 |
+
.hero-subtitle {text-align:center;color:#4a5568;margin-top:0;}
|
| 1025 |
.version-chip {
|
| 1026 |
display:inline-block; padding:4px 10px; border:1px solid #c7d2e3; border-radius:999px;
|
| 1027 |
font-size:12px; color:#334155; background:#f8fbff; margin-top:4px;
|
| 1028 |
}
|
| 1029 |
.version-foot {font-size:12px; color:#64748b; text-align:center; margin-top:8px;}
|
| 1030 |
+
.advanced-box {margin-top: 8px; padding: 8px; border: 1px dashed #cbd5e1; border-radius: 8px;}
|
| 1031 |
+
|
| 1032 |
+
@media (prefers-color-scheme: dark) {
|
| 1033 |
+
body, .gradio-container {background: #0f172a !important; color: #e2e8f0 !important;}
|
| 1034 |
+
.section-box {background: #111827 !important; border-color: #334155 !important;}
|
| 1035 |
+
.method-box {background: #0b1220 !important; border-color: #334155 !important;}
|
| 1036 |
+
.hero-subtitle {color: #94a3b8 !important;}
|
| 1037 |
+
.version-chip {background: #111827 !important; border-color: #475569 !important; color: #cbd5e1 !important;}
|
| 1038 |
+
.version-foot {color: #94a3b8 !important;}
|
| 1039 |
+
.advanced-box {border-color: #475569 !important;}
|
| 1040 |
+
}
|
| 1041 |
"""
|
| 1042 |
|
| 1043 |
|
|
|
|
| 1045 |
gr.HTML(
|
| 1046 |
"""
|
| 1047 |
<h1 style="text-align:center;margin-bottom:4px;">Pengecekan dan Klasifikasi Kehadiran Berbasis YOLOv8</h1>
|
| 1048 |
+
<p class="hero-subtitle">
|
| 1049 |
Implementasi klasifikasi kehadiran mahasiswa dalam ruang kelas berbasis citra menggunakan YOLOv8.
|
| 1050 |
</p>
|
| 1051 |
"""
|
|
|
|
| 1093 |
minimum=0,
|
| 1094 |
)
|
| 1095 |
conf_input = gr.Slider(0.1, 1.0, value=0.25, step=0.05, label="Parameter Confidence (Deteksi)")
|
| 1096 |
+
with gr.Accordion("Advanced Options", open=False, elem_classes=["advanced-box"]):
|
| 1097 |
+
annotation_style = gr.Radio(
|
| 1098 |
+
choices=["Border + Label", "Label saja"],
|
| 1099 |
+
value="Border + Label",
|
| 1100 |
+
label="Gaya anotasi objek",
|
| 1101 |
+
info="Pilih tampilan bbox atau label-only untuk person 1, person 2, ...",
|
| 1102 |
+
)
|
| 1103 |
run_btn = gr.Button("Jalankan Deteksi dan Klasifikasi", variant="primary")
|
| 1104 |
|
| 1105 |
with gr.Column(elem_classes=["section-box"]):
|
|
|
|
| 1162 |
|
| 1163 |
run_btn_evt = run_btn.click(
|
| 1164 |
fn=run_check_and_classify,
|
| 1165 |
+
inputs=[image_input, conf_input, attendance_input, model_choice, inference_mode, annotation_style],
|
| 1166 |
outputs=[output_image, class_result_md, comparison_chart, predicted_state, predicted_label_md],
|
| 1167 |
)
|
| 1168 |
run_btn_evt.then(
|