refactor(halcon): single-skill plugin layout → invoke as /halcon (not /halcon:halcon)
Move SKILL.md + references/ scripts/ evals/ from skills/halcon/ up to the plugin root. Per Claude Code plugins-reference, a plugin with SKILL.md at its root and no skills/ subdir is auto-loaded as a single-skill plugin (v2.1.142+), so the invocation name = frontmatter name = halcon → clean /halcon. Bump plugin.json 2.0.0 → 2.0.1 so existing installs receive the update. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+40
@@ -0,0 +1,40 @@
|
||||
"""Shared helpers for the HALCON runner scripts."""
|
||||
import re
|
||||
|
||||
_INT_RE = re.compile(r"^[+-]?\d+$")
|
||||
_FLOAT_RE = re.compile(r"^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$")
|
||||
|
||||
|
||||
def coerce(value: str):
|
||||
"""Turn a CLI string into int/float/str, or a list for comma-separated input.
|
||||
|
||||
Absolute paths (C:/..., /mnt/...) and anything non-numeric stay strings.
|
||||
Use a trailing comma or commas to force a tuple, e.g. '1,2,3' -> [1,2,3].
|
||||
"""
|
||||
if "," in value:
|
||||
return [coerce(v) for v in value.split(",") if v != ""]
|
||||
if _INT_RE.match(value):
|
||||
return int(value)
|
||||
if _FLOAT_RE.match(value) and not value.strip().startswith(("C:", "c:")):
|
||||
return float(value)
|
||||
return value
|
||||
|
||||
|
||||
def jsonable(v):
|
||||
"""Make a HALCON output control value JSON-serializable."""
|
||||
if isinstance(v, (list, tuple)):
|
||||
return [jsonable(x) for x in v]
|
||||
if isinstance(v, (int, float, str, bool)) or v is None:
|
||||
return v
|
||||
return str(v)
|
||||
|
||||
|
||||
def parse_set(pairs):
|
||||
"""['Name=Value', ...] -> [(name, coerced_value), ...]."""
|
||||
out = []
|
||||
for p in pairs or []:
|
||||
if "=" not in p:
|
||||
raise SystemExit(f"--set expects Name=Value, got: {p!r}")
|
||||
name, _, raw = p.partition("=")
|
||||
out.append((name.strip(), coerce(raw)))
|
||||
return out
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
"""Smoke-test the HALCON environment on this machine.
|
||||
|
||||
Verifies: the Python binding loads, the native runtime links, the license is
|
||||
valid (a real operator runs), and HDevEngine imports.
|
||||
|
||||
Run (from the project dir, via uv):
|
||||
/mnt/c/Users/NAURA/.local/bin/uv.exe run python .claude/skills/halcon/scripts/check_env.py
|
||||
"""
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
import halcon as ha
|
||||
except Exception as e: # binding not installed / wrong interpreter
|
||||
print("FAIL: could not import 'halcon'. Are you running the project venv "
|
||||
"via uv? Error:", e)
|
||||
return 1
|
||||
|
||||
try:
|
||||
print("HALCON library version:", ha.get_system("version"))
|
||||
img = ha.gen_image_const("byte", 64, 48) # exercises the license
|
||||
w = ha.get_image_size(img)
|
||||
print("gen_image_const OK, size (w,h):", w)
|
||||
region = ha.threshold(img, 0, 128)
|
||||
area, _, _ = ha.area_center(region)
|
||||
print("threshold + area_center OK, area:", area)
|
||||
except Exception as e:
|
||||
print("FAIL: an operator call failed. This is usually a LICENSE problem "
|
||||
"(missing/expired monthly .dat in <install>\\license\\). Error:", e)
|
||||
return 2
|
||||
|
||||
try:
|
||||
from halcon.hdevengine import HDevEngine # noqa: F401
|
||||
print("HDevEngine import OK")
|
||||
except Exception as e:
|
||||
print("WARN: HDevEngine import failed:", e)
|
||||
return 3
|
||||
|
||||
print("HALCON environment OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
"""Rebuild the bundled PDF table-of-contents index (references/pdf/_toc.json).
|
||||
|
||||
Self-contained: reads every PDF bundled under the skill's references/pdf/ and
|
||||
writes the multi-level TOC next to them. Run after adding/updating manuals.
|
||||
|
||||
/mnt/c/Users/NAURA/.local/bin/uv.exe run --no-project --with pymupdf \
|
||||
python .claude/skills/halcon/scripts/extract_toc.py
|
||||
"""
|
||||
import fitz, os, glob, json
|
||||
|
||||
# references/pdf/ relative to this script (scripts/ -> ../references/pdf)
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
PDF_DIR = os.path.normpath(os.path.join(HERE, "..", "references", "pdf"))
|
||||
OUT = os.path.join(PDF_DIR, "_toc.json")
|
||||
|
||||
out = {}
|
||||
for p in sorted(glob.glob(os.path.join(PDF_DIR, "*.pdf"))):
|
||||
name = os.path.basename(p)
|
||||
try:
|
||||
doc = fitz.open(p)
|
||||
out[name] = {"pages": doc.page_count, "toc": doc.get_toc()} # [level,title,page]
|
||||
doc.close()
|
||||
except Exception as e:
|
||||
out[name] = {"error": str(e)}
|
||||
|
||||
with open(OUT, "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, ensure_ascii=False, indent=1)
|
||||
|
||||
for name, v in out.items():
|
||||
if "error" in v:
|
||||
print(f"{name}: ERROR {v['error']}")
|
||||
else:
|
||||
print(f"{name}: {v['pages']} pages, {len(v['toc'])} toc entries")
|
||||
print("wrote", OUT)
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
"""Run one HDevelop external procedure (.hdvp) via HDevEngine and print outputs.
|
||||
|
||||
This is the generic form of the tested run_compare.py driver. Pass input control
|
||||
params with --set, name the outputs to read with --get; results print as a JSON
|
||||
object on stdout.
|
||||
|
||||
Example:
|
||||
/mnt/c/Users/NAURA/.local/bin/uv.exe run python \
|
||||
.claude/skills/halcon/scripts/run_procedure.py \
|
||||
--proc-path C:/workspace/agent-studio/halcon-001 \
|
||||
--proc find_circles \
|
||||
--set ImageFile=C:/data/a.png \
|
||||
--set RoiFile=C:/workspace/agent-studio/halcon-001/roi.hobj \
|
||||
--get FitRow --get FitCol --get MetroRow --get MetroCol
|
||||
|
||||
Notes:
|
||||
- Always use ABSOLUTE paths for images/ROIs (HDevEngine has no cwd notion).
|
||||
- Numeric --set values are auto-typed; use commas for a tuple (e.g. --set T=10,20).
|
||||
- Wrap in a loop from your own Python for batch processing.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from _common import parse_set, jsonable
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Run a HALCON .hdvp via HDevEngine")
|
||||
ap.add_argument("--proc-path", required=True,
|
||||
help="Directory containing the .hdvp (set_procedure_path)")
|
||||
ap.add_argument("--proc", required=True,
|
||||
help="Procedure base name, no extension (e.g. find_circles)")
|
||||
ap.add_argument("--set", dest="sets", action="append", default=[],
|
||||
metavar="Name=Value", help="Input control param (repeatable)")
|
||||
ap.add_argument("--get", dest="gets", action="append", default=[],
|
||||
metavar="Name", help="Output control param to read (repeatable)")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
from halcon.hdevengine import (
|
||||
HDevEngine, HDevProcedure, HDevProcedureCall, HDevEngineError,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"ERROR: cannot import HDevEngine ({e}). Run via the project venv "
|
||||
f"using uv.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
eng = HDevEngine()
|
||||
eng.set_procedure_path(args.proc_path)
|
||||
try:
|
||||
proc = HDevProcedure.load_external(args.proc)
|
||||
except HDevEngineError as e:
|
||||
print(f"ERROR: could not load procedure '{args.proc}' from "
|
||||
f"'{args.proc_path}': {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
call = HDevProcedureCall(proc)
|
||||
call.reset()
|
||||
for name, value in parse_set(args.sets):
|
||||
call.set_input_control_param_by_name(name, value)
|
||||
|
||||
try:
|
||||
call.execute()
|
||||
except HDevEngineError as e:
|
||||
print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
|
||||
return 3
|
||||
|
||||
out = {}
|
||||
for name in args.gets:
|
||||
try:
|
||||
out[name] = jsonable(call.get_output_control_param_by_name(name))
|
||||
except HDevEngineError as e:
|
||||
out[name] = {"error": str(e)}
|
||||
print(json.dumps({"ok": True, "outputs": out}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
"""Run a whole HDevelop program (.hdev) via HDevEngine and read control vars back.
|
||||
|
||||
Use when the logic lives in a program's main rather than a parameterized
|
||||
procedure. Outputs print as a JSON object on stdout.
|
||||
|
||||
Example:
|
||||
/mnt/c/Users/NAURA/.local/bin/uv.exe run python \
|
||||
.claude/skills/halcon/scripts/run_program.py \
|
||||
--program C:/workspace/agent-studio/halcon-001/demo_prog.hdev \
|
||||
--get Total --get Area --get Message
|
||||
|
||||
Notes:
|
||||
- The program must be self-contained and non-interactive (no draw_*, no stop()).
|
||||
- Use ABSOLUTE paths inside the program (no cwd notion under HDevEngine).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from _common import jsonable
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Run a HALCON .hdev via HDevEngine")
|
||||
ap.add_argument("--program", required=True, help="Absolute path to the .hdev")
|
||||
ap.add_argument("--get", dest="gets", action="append", default=[],
|
||||
metavar="Name", help="Control variable to read (repeatable)")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
from halcon.hdevengine import HDevProgram, HDevProgramCall, HDevEngineError
|
||||
except Exception as e:
|
||||
print(f"ERROR: cannot import HDevEngine ({e}). Run via the project venv "
|
||||
f"using uv.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
program = HDevProgram(args.program)
|
||||
call = HDevProgramCall(program)
|
||||
try:
|
||||
call.execute()
|
||||
except HDevEngineError as e:
|
||||
print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
|
||||
return 3
|
||||
|
||||
out = {}
|
||||
for name in args.gets:
|
||||
try:
|
||||
out[name] = jsonable(call.get_control_var_by_name(name))
|
||||
except HDevEngineError as e:
|
||||
out[name] = {"error": str(e)}
|
||||
print(json.dumps({"ok": True, "vars": out}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+192
@@ -0,0 +1,192 @@
|
||||
"""Teach an ROI once (human draws), persist it as a .hobj for headless reuse.
|
||||
|
||||
The rectangle case (default, the common "box a Mark" flow) uses an interactive
|
||||
matplotlib viewer with **pan (right-drag), zoom (scroll wheel), and box-select
|
||||
(left-drag)** — smooth even on multi-megapixel images. circle/region fall back to
|
||||
HALCON's native draw_* window. Downstream procedures read_region + reduce_domain
|
||||
the saved .hobj, so no human is needed after teaching.
|
||||
|
||||
Run (needs a visible desktop — this is the interactive seam):
|
||||
/mnt/c/Users/NAURA/.local/bin/uv.exe run python \
|
||||
.claude/skills/halcon/scripts/teach_roi.py \
|
||||
--image-file "C:/data/frames/a.bmp" \
|
||||
--out C:/workspace/agent-studio/halcon-001/roi.hobj
|
||||
|
||||
Controls (rectangle): left-drag = box · right-drag = pan · wheel = zoom ·
|
||||
Enter = save · q/Esc = cancel.
|
||||
|
||||
Headless (no GUI, scripted): add --box r1,c1,r2,c2 to write a rectangle directly.
|
||||
|
||||
Requires matplotlib + numpy in the venv (one-time):
|
||||
/mnt/c/Users/NAURA/.local/bin/uv.exe pip install matplotlib numpy
|
||||
|
||||
Tip: draw TIGHTLY around the target features. A loose ROI captures extra
|
||||
contours and breaks downstream count checks (e.g. "expected 16 circles").
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def pick_reference_image(ha, image_dir, image_file):
|
||||
if image_file:
|
||||
return image_file
|
||||
files = ha.list_files(image_dir, "files")
|
||||
imgs = sorted(ha.tuple_regexp_select(files, r"\.(png|bmp|jpg|jpeg|tif|tiff)$"))
|
||||
if not imgs:
|
||||
raise SystemExit(f"No images found in {image_dir}")
|
||||
return imgs[0]
|
||||
|
||||
|
||||
def load_gray(ha, image_file):
|
||||
# HALCON 默认 clip_region=true 会把区域裁到 128x128,全分辨率坐标建的 region
|
||||
# 会被裁空 -> 写盘回读 bbox=0,0,0,0。建/读区域前务必关掉。
|
||||
ha.set_system("clip_region", "false")
|
||||
img = ha.read_image(image_file)
|
||||
if ha.count_channels(img)[0] >= 3:
|
||||
img = ha.rgb1_to_gray(img)
|
||||
w, h = ha.get_image_size(img)
|
||||
return ha.himage_as_numpy_array(img), int(h[0]), int(w[0])
|
||||
|
||||
|
||||
def pick_rectangle_interactive(arr, max_disp):
|
||||
"""matplotlib pan/zoom/box-select; returns (r1,c1,r2,c2) in image coords or None."""
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.widgets import RectangleSelector
|
||||
|
||||
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
|
||||
plt.rcParams["axes.unicode_minus"] = False
|
||||
|
||||
# Downsample for display (fast redraws), map coords back to full res via extent.
|
||||
H, W = arr.shape
|
||||
step = max(1, round(max(H, W) / max_disp))
|
||||
disp = arr[::step, ::step]
|
||||
|
||||
fig, ax = plt.subplots()
|
||||
ax.imshow(disp, cmap="gray", origin="upper", extent=(0, W, H, 0),
|
||||
interpolation="nearest", aspect="equal")
|
||||
ax.set_xlim(0, W); ax.set_ylim(H, 0)
|
||||
ax.set_title("left=box right-drag=pan wheel=zoom Enter=save q=cancel")
|
||||
|
||||
box, pan = {}, {}
|
||||
|
||||
def on_select(p, r):
|
||||
box.update(r1=p.ydata, c1=p.xdata, r2=r.ydata, c2=r.xdata)
|
||||
|
||||
# keep the reference or RectangleSelector is GC'd and box-select stops working
|
||||
ax._roi_selector = RectangleSelector(
|
||||
ax, on_select, useblit=True, button=[1], minspanx=3, minspany=3,
|
||||
interactive=True)
|
||||
|
||||
def on_scroll(ev):
|
||||
if ev.inaxes is not ax:
|
||||
return
|
||||
s = 0.8 if ev.button == "up" else 1.25
|
||||
x0, x1 = ax.get_xlim(); y0, y1 = ax.get_ylim()
|
||||
ax.set_xlim(ev.xdata - (ev.xdata - x0) * s, ev.xdata + (x1 - ev.xdata) * s)
|
||||
ax.set_ylim(ev.ydata - (ev.ydata - y0) * s, ev.ydata + (y1 - ev.ydata) * s)
|
||||
fig.canvas.draw_idle()
|
||||
|
||||
def on_press(ev):
|
||||
if ev.button == 3 and ev.inaxes is ax:
|
||||
pan["p"] = (ev.xdata, ev.ydata)
|
||||
|
||||
def on_motion(ev):
|
||||
if "p" in pan and ev.xdata is not None and ev.inaxes is ax:
|
||||
dx, dy = ev.xdata - pan["p"][0], ev.ydata - pan["p"][1]
|
||||
x0, x1 = ax.get_xlim(); y0, y1 = ax.get_ylim()
|
||||
ax.set_xlim(x0 - dx, x1 - dx); ax.set_ylim(y0 - dy, y1 - dy)
|
||||
fig.canvas.draw_idle()
|
||||
|
||||
def on_release(ev):
|
||||
if ev.button == 3:
|
||||
pan.pop("p", None)
|
||||
|
||||
def on_key(ev):
|
||||
if ev.key == "enter" and box:
|
||||
plt.close(fig)
|
||||
elif ev.key in ("q", "escape"):
|
||||
box.clear(); plt.close(fig)
|
||||
|
||||
for name, fn in [("scroll_event", on_scroll), ("button_press_event", on_press),
|
||||
("motion_notify_event", on_motion),
|
||||
("button_release_event", on_release), ("key_press_event", on_key)]:
|
||||
fig.canvas.mpl_connect(name, fn)
|
||||
plt.show()
|
||||
if not box:
|
||||
return None
|
||||
return box["r1"], box["c1"], box["r2"], box["c2"]
|
||||
|
||||
|
||||
def save_rectangle(ha, out, r1, c1, r2, c2, H, W):
|
||||
r1, r2 = sorted((max(0, round(r1)), min(H - 1, round(r2))))
|
||||
c1, c2 = sorted((max(0, round(c1)), min(W - 1, round(c2))))
|
||||
ha.write_region(ha.gen_rectangle1(r1, c1, r2, c2), out)
|
||||
print(f"ROI saved: {out}")
|
||||
print(f"Rectangle Row1={r1} Col1={c1} Row2={r2} Col2={c2}")
|
||||
|
||||
|
||||
def draw_native(ha, ref, shape, out):
|
||||
"""circle / region via HALCON's own draw window (no pan/zoom)."""
|
||||
img = ha.read_image(ref)
|
||||
width, height = ha.get_image_size(img)
|
||||
width, height = width[0], height[0]
|
||||
win = ha.open_window(0, 0, 1024, 768, 0, "visible", "")
|
||||
ha.set_part(win, 0, 0, height - 1, width - 1)
|
||||
ha.disp_obj(img, win)
|
||||
ha.set_color(win, "green")
|
||||
print(f"Draw a {shape} with the left mouse button, release to finish ...", flush=True)
|
||||
if shape == "circle":
|
||||
row, col, radius = ha.draw_circle(win)
|
||||
region = ha.gen_circle(row, col, radius)
|
||||
print(f"Circle Row={row:.1f} Col={col:.1f} Radius={radius:.1f}")
|
||||
else: # freehand region
|
||||
region = ha.draw_region(win)
|
||||
ha.write_region(region, out)
|
||||
ha.close_window(win)
|
||||
print(f"ROI saved: {out}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Teach an ROI and write it to disk")
|
||||
src = ap.add_mutually_exclusive_group(required=True)
|
||||
src.add_argument("--image-dir", help="Directory; first sorted image is shown")
|
||||
src.add_argument("--image-file", help="Explicit reference image to display")
|
||||
ap.add_argument("--out", required=True, help="Output region file, e.g. .../roi.hobj")
|
||||
ap.add_argument("--shape", choices=["rectangle", "circle", "region"],
|
||||
default="rectangle",
|
||||
help="rectangle = interactive pan/zoom picker; circle/region = HALCON draw")
|
||||
ap.add_argument("--disp", type=int, default=1000,
|
||||
help="rectangle picker: display long-side px cap (smaller=faster/blurrier)")
|
||||
ap.add_argument("--box", help="rectangle headless (no GUI): r1,c1,r2,c2 image coords")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
import halcon as ha
|
||||
except Exception as e:
|
||||
print("ERROR: cannot import 'halcon'. Run via the project venv using uv.",
|
||||
e, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
ref = pick_reference_image(ha, args.image_dir, args.image_file)
|
||||
print(f"Reference image: {ref}")
|
||||
|
||||
if args.shape != "rectangle":
|
||||
draw_native(ha, ref, args.shape, args.out)
|
||||
return 0
|
||||
|
||||
arr, H, W = load_gray(ha, ref)
|
||||
if args.box:
|
||||
r1, c1, r2, c2 = (float(v) for v in args.box.split(","))
|
||||
save_rectangle(ha, args.out, r1, c1, r2, c2, H, W)
|
||||
return 0
|
||||
|
||||
rect = pick_rectangle_interactive(arr, args.disp)
|
||||
if rect is None:
|
||||
print("cancelled, nothing saved")
|
||||
return 0
|
||||
save_rectangle(ha, args.out, *rect, H, W)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user