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:
2026-07-10 23:14:39 +08:00
parent 36eadffa87
commit 3fe919e37c
44 changed files with 1 additions and 1 deletions
+40
View File
@@ -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
+45
View File
@@ -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())
+34
View File
@@ -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)
+80
View File
@@ -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())
+56
View File
@@ -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())
+192
View File
@@ -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())